Merge branch 'main' into chunk-data

Resolved 29 conflicting files. The notable decisions:

Confidence: main's tool-calling gate (RequiredProviderConfidence) and this
branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule
on the same axis, so they are now one field. Both tool results and data
sources raise it through RequireProviderConfidence(). The gate checks the
level strictly and no longer exempts providers trusted by configuration:
TrustedProviderIds is documented as applying to data-source security checks
only, and organizations set confidence through DataConfidence
.CustomConfidenceScheme instead. The security axis (DataSecurity, ERI,
IsTrustedForDataSourceSecurityChecks) is unchanged.

Provider creation: main's CreateProvider signature won (hfEndpointKind,
capabilityOverrides, no model parameter); tokenizerPath was added to it and
is set for every provider, including the new Hetzner, IONOS and LiteLLM.
Provider and EmbeddingProvider combine the record parameters, Lua parsing and
Lua serialization of both sides.

File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the
legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's
SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the
runtime already reads. Both sides had added a conflicting HTML filter; the
reading family keeps the name, and the export path uses a narrow
HTML_DOCUMENT, following the existing LATEX/TEX split.

Runtime: main's file_data.rs is the base, including the prompt-injection
sanitizer and the extraction routes. Token counting and chunk segmentation
moved into take_released, so they act on the text the filter has released
rather than on text it is still holding. A failed count is logged and left
out instead of ending the extraction, because the app counts such a segment
itself.

Data sources: the participating-provider checks of this branch are kept, and
main's GetAllowedDataSources overload now builds on them. DirectChatService
resolves the launched chat's data source options before the check, so filter
and chat see the same options.

.NET and Rust both build clean; I18N regenerated to 4060 keys.
This commit is contained in:
Thorsten Sommer committed 2026-09-05 21:20:33 +02:00
commit fe35630eff
640 files changed
+45059 -4952

No files matched your search

+43 -108
View File
@@ -1,4 +1,5 @@
using System.Linq.Expressions;
using System.Text.Json;
using AIStudio.Settings.DataModel;
@@ -11,7 +12,7 @@ namespace AIStudio.Settings;
/// <typeparam name="TValue">The type of the configuration property value.</typeparam>
public record ConfigMeta<TClass, TValue> : ConfigMetaBase
{
public ConfigMeta(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression)
public ConfigMeta(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression) : base(SettingsManager.ToSettingName(propertyExpression))
{
this.ConfigSelection = configSelection;
this.PropertyExpression = propertyExpression;
@@ -26,130 +27,64 @@ public record ConfigMeta<TClass, TValue> : ConfigMetaBase
/// The expression to select the property within the configuration class.
/// </summary>
private Expression<Func<TClass, TValue>> PropertyExpression { get; }
/// <summary>
/// Indicates whether the configuration is locked by a configuration plugin.
/// </summary>
public bool IsLocked { get; private set; }
/// <summary>
/// The ID of the plugin that locked this configuration.
/// </summary>
public Guid LockedByConfigPluginId { get; private set; }
/// <summary>
/// How this setting is managed by a configuration plugin, if at all.
/// </summary>
public ManagedConfigurationMode? ManagedMode { get; private set; }
/// <summary>
/// The ID of the plugin that currently provides an editable default value.
/// </summary>
public Guid EditableDefaultByConfigPluginId { get; private set; }
/// <summary>
/// The default value for the configuration property. This is used when resetting the property to its default state.
/// </summary>
public required TValue Default { get; init; }
/// <summary>
/// Indicates whether a plugin contribution is available.
/// The additive value contributions, one per contributing configuration plugin.
/// </summary>
public bool HasPluginContribution { get; private set; }
/// <remarks>
/// Every configuration plugin keeps its own contribution, so removing one of them leaves the
/// contributions of the others intact. Callers that need the overall contribution combine the
/// values themselves: only they know how to combine the concrete type.
/// </remarks>
public IReadOnlyDictionary<Guid, TValue> PluginContributions => this.pluginContributions;
/// <inheritdoc/>
public override IReadOnlyCollection<Guid> ContributingConfigPluginIds => this.pluginContributions.Keys;
private readonly Dictionary<Guid, TValue> pluginContributions = [];
/// <summary>
/// The additive value contribution provided by a configuration plugin.
/// Stores the additive contribution of one configuration plugin, replacing its previous one.
/// </summary>
public TValue PluginContribution { get; private set; } = default!;
/// <param name="value">The contributed value.</param>
/// <param name="pluginId">The contributing configuration plugin.</param>
public void SetPluginContribution(TValue value, Guid pluginId) => this.pluginContributions[pluginId] = value;
/// <summary>
/// The ID of the plugin that provided the additive value contribution.
/// </summary>
public Guid PluginContributionByConfigPluginId { get; private set; }
/// <inheritdoc/>
public override bool RemovePluginContribution(Guid configPluginId) => this.pluginContributions.Remove(configPluginId);
/// <summary>
/// Locks the configuration state, indicating that it is controlled by a specific plugin.
/// </summary>
/// <param name="pluginId">The ID of the plugin that is locking this configuration.</param>
public void LockConfiguration(Guid pluginId)
/// <inheritdoc/>
public override string SerializeCurrentValue() => ManagedConfiguration.SerializeManagedScalarValue(this.GetValue());
/// <inheritdoc/>
protected override string SerializeCurrentValueAsJson() => JsonSerializer.Serialize(this.GetValue(), SettingsManager.JSON_OPTIONS);
/// <inheritdoc/>
protected override bool TrySetValueFromJson(string json)
{
this.IsLocked = true;
this.LockedByConfigPluginId = pluginId;
this.ManagedMode = ManagedConfigurationMode.LOCKED;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary>
/// Resets the locked state of the configuration, allowing it to be modified again.
/// This will also reset the property to its default value.
/// </summary>
public void ResetLockedConfiguration()
{
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
try
{
var value = JsonSerializer.Deserialize<TValue>(json, SettingsManager.JSON_OPTIONS);
if (value is null)
return false;
this.Reset();
this.SetValue(value);
return true;
}
catch (Exception e)
{
Log.LogWarning(e, $"Was not able to restore the value of the setting '{this.SettingName}' from its snapshot '{json}'. Using the default value instead.");
return false;
}
}
/// <summary>
/// Unlocks the configuration state without changing the current value.
/// </summary>
public void UnlockConfiguration()
{
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
}
/// <summary>
/// Marks the setting as having an editable default provided by a configuration plugin.
/// </summary>
public void SetEditableDefaultConfiguration(Guid pluginId)
{
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
this.ManagedMode = ManagedConfigurationMode.EDITABLE_DEFAULT;
this.EditableDefaultByConfigPluginId = pluginId;
}
/// <summary>
/// Clears the editable-default state without changing the current value.
/// </summary>
public void ClearEditableDefaultConfiguration()
{
if (this.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
this.ManagedMode = null;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary>
/// Stores an additive plugin contribution.
/// </summary>
public void SetPluginContribution(TValue value, Guid pluginId)
{
this.PluginContribution = value;
this.PluginContributionByConfigPluginId = pluginId;
this.HasPluginContribution = true;
}
/// <summary>
/// Clears the additive plugin contribution without changing the current value.
/// </summary>
public void ClearPluginContribution()
{
this.PluginContribution = default!;
this.PluginContributionByConfigPluginId = Guid.Empty;
this.HasPluginContribution = false;
}
/// <summary>
/// Resets the configuration property to its default value.
/// </summary>
private void Reset()
/// <inheritdoc/>
protected override void Reset()
{
var configInstance = this.ConfigSelection.Compile().Invoke(SettingsManagerAccess.ConfigurationData);
var memberExpression = this.PropertyExpression.GetMemberExpression();
@@ -1,6 +1,266 @@
namespace AIStudio.Settings;
public abstract record ConfigMetaBase : IConfig
/// <summary>
/// The type-independent part of the configuration metadata: which configuration plugin manages
/// the setting, and in which way.
/// </summary>
/// <remarks>
/// The managed state lives here so that it can be processed without knowing the setting's type,
/// e.g. when cleaning up settings whose configuration plugin was removed.
/// </remarks>
public abstract record ConfigMetaBase(string SettingName) : IConfig
{
protected static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
protected static ILogger Log => Program.LOGGER_FACTORY.CreateLogger(nameof(ConfigMetaBase));
/// <summary>
/// The persisted name of the configuration setting.
/// </summary>
public string SettingName { get; } = SettingName;
/// <summary>
/// Indicates whether the configuration is locked by a configuration plugin.
/// </summary>
public bool IsLocked { get; private set; }
/// <summary>
/// The ID of the plugin that locked this configuration.
/// </summary>
public Guid LockedByConfigPluginId { get; private set; }
/// <summary>
/// How this setting is managed by a configuration plugin, if at all.
/// </summary>
public ManagedConfigurationMode? ManagedMode { get; private set; }
/// <summary>
/// The ID of the plugin that currently provides an editable default value.
/// </summary>
public Guid EditableDefaultByConfigPluginId { get; private set; }
/// <summary>
/// The configuration plugins which contribute to this setting.
/// </summary>
/// <remarks>
/// Contributions are additive, so several configuration plugins may contribute at the same time
/// and each of them keeps its own contribution. An organization might enable one preview feature
/// for everybody and another one for a single department, for example.
/// </remarks>
public abstract IReadOnlyCollection<Guid> ContributingConfigPluginIds { get; }
/// <summary>
/// Indicates whether at least one configuration plugin contributes to this setting.
/// </summary>
public bool HasPluginContribution => this.ContributingConfigPluginIds.Count > 0;
/// <summary>
/// Locks the configuration state, indicating that it is controlled by a specific plugin.
/// </summary>
/// <param name="pluginId">The ID of the plugin that is locking this configuration.</param>
public void LockConfiguration(Guid pluginId)
{
this.IsLocked = true;
this.LockedByConfigPluginId = pluginId;
this.ManagedMode = ManagedConfigurationMode.LOCKED;
this.EditableDefaultByConfigPluginId = Guid.Empty;
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations[this.SettingName] = pluginId;
}
/// <summary>
/// Restores persisted locked configuration metadata after settings were loaded.
/// </summary>
public void RestoreLockedConfiguration()
{
if (this.IsLocked || this.ManagedMode is not null)
return;
if (!SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.TryGetValue(this.SettingName, out var pluginId) || pluginId == Guid.Empty)
return;
this.IsLocked = true;
this.LockedByConfigPluginId = pluginId;
this.ManagedMode = ManagedConfigurationMode.LOCKED;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary>
/// Resets the locked state of the configuration, allowing it to be modified again.
/// This will also reset the property to its default value.
/// </summary>
public void ResetLockedConfiguration()
{
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.Remove(this.SettingName);
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
this.RestoreUserValueOrDefault();
}
/// <summary>
/// Unlocks the configuration state without changing the current value.
/// </summary>
public void UnlockConfiguration()
{
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.Remove(this.SettingName);
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
this.ManagedMode = null;
}
/// <summary>
/// Marks the setting as having an editable default provided by a configuration plugin.
/// </summary>
public void SetEditableDefaultConfiguration(Guid pluginId)
{
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.Remove(this.SettingName);
this.IsLocked = false;
this.LockedByConfigPluginId = Guid.Empty;
this.ManagedMode = ManagedConfigurationMode.EDITABLE_DEFAULT;
this.EditableDefaultByConfigPluginId = pluginId;
}
/// <summary>
/// Clears the editable-default state without changing the current value.
/// </summary>
public void ClearEditableDefaultConfiguration()
{
if (this.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
this.ManagedMode = null;
this.EditableDefaultByConfigPluginId = Guid.Empty;
}
/// <summary>
/// Clears the editable-default state and hands the setting back to the user.
/// </summary>
/// <remarks>
/// Without a snapshot of the user's value, the current value stays as it is. That is the
/// difference to a locked setting: the user was allowed to change an editable default all
/// along, so its value is a plausible choice of theirs. Resetting it to the app's default would
/// take away something nobody asked us to remove.
/// </remarks>
/// <param name="keepCurrentValue">
/// True when the user has changed the value in the meantime. Their decision outlives the
/// configuration plugin, so the snapshot is dropped instead of applied.
/// </param>
public void ResetEditableDefaultConfiguration(bool keepCurrentValue)
{
this.ClearEditableDefaultConfiguration();
if (keepCurrentValue)
this.ClearUserValueSnapshot();
else
this.TryRestoreUserValueSnapshot();
}
/// <summary>
/// Removes the contribution of one configuration plugin without changing the current value.
/// </summary>
/// <param name="configPluginId">The configuration plugin whose contribution is removed.</param>
/// <returns>True when that plugin had a contribution, otherwise false.</returns>
public abstract bool RemovePluginContribution(Guid configPluginId);
/// <summary>
/// Indicates whether the value the user had chosen before a configuration plugin took over
/// this setting is still available.
/// </summary>
public bool HasUserValueSnapshot => SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots.ContainsKey(this.SettingName);
/// <summary>
/// Remembers the current value as the user's value, so that it can be restored once no
/// configuration plugin manages this setting anymore.
/// </summary>
/// <remarks>
/// Only an unmanaged setting holds a value which belongs to the user. When one configuration
/// plugin takes a setting over from another, the current value belongs to the previous plugin,
/// so the snapshot of the user's value must survive that handover untouched.<br/><br/>
/// The persisted editable default counts as managed as well: unlike a locked setting, it is not
/// restored into the in-memory state when the settings are loaded, so right after a start it is
/// the only evidence that a configuration plugin is already in charge.
/// </remarks>
public void CaptureUserValueSnapshot()
{
if (this.ManagedMode is not null || SettingsManagerAccess.ConfigurationData.ManagedEditableDefaults.ContainsKey(this.SettingName))
return;
var snapshots = SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots;
if (snapshots.ContainsKey(this.SettingName))
return;
snapshots[this.SettingName] = this.SerializeCurrentValueAsJson();
}
/// <summary>
/// Restores the value the user had chosen before a configuration plugin took over this setting.
/// </summary>
/// <remarks>
/// The snapshot is consumed either way: when it cannot be applied, keeping it would mean trying
/// the same broken value again on every start.
/// </remarks>
/// <returns>True when a snapshot was available and could be applied, otherwise false.</returns>
private bool TryRestoreUserValueSnapshot()
{
var snapshots = SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots;
if (!snapshots.Remove(this.SettingName, out var snapshot))
return false;
return this.TrySetValueFromJson(snapshot);
}
/// <summary>
/// Drops the snapshot of the user's value without changing the current value.
/// </summary>
/// <returns>True when a snapshot was dropped, otherwise false.</returns>
public bool ClearUserValueSnapshot() => SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots.Remove(this.SettingName);
/// <summary>
/// Serializes the current value the same way the managed states record it.
/// </summary>
/// <remarks>
/// This is meant for comparisons, e.g. to tell whether the user has changed an editable default
/// in the meantime. It is not meant for restoring a value: the representation is lossy.
/// </remarks>
public abstract string SerializeCurrentValue();
/// <summary>
/// Restores the user's value, or falls back to the default value when no snapshot is available.
/// </summary>
/// <remarks>
/// Settings which a configuration plugin managed before this app version has no snapshot, and
/// neither has a setting whose value the user never changed. The default value is the best
/// answer in both cases.
/// </remarks>
private void RestoreUserValueOrDefault()
{
if (this.TryRestoreUserValueSnapshot())
return;
this.Reset();
}
/// <summary>
/// Serializes the current value as JSON, so that it can be restored without losing information.
/// </summary>
protected abstract string SerializeCurrentValueAsJson();
/// <summary>
/// Applies a value which was serialized by SerializeCurrentValueAsJson.
/// </summary>
/// <param name="json">The serialized value.</param>
/// <returns>True when the value could be applied, otherwise false.</returns>
protected abstract bool TrySetValueFromJson(string json);
/// <summary>
/// Resets the configuration property to its default value.
/// </summary>
protected abstract void Reset();
}
@@ -27,6 +27,7 @@ public enum ConfigurableAssistant
SLIDE_BUILDER_ASSISTANT,
LOG_VIEWER_ASSISTANT,
VISUAL_BRIEFING_ASSISTANT,
BATCH_PROCESSING_ASSISTANT,
// ReSharper disable InconsistentNaming
I18N_ASSISTANT,
@@ -0,0 +1,9 @@
namespace AIStudio.Settings;
/// <summary>
/// A data structure to map a name to a value.
/// </summary>
/// <param name="Name">The name of the value, to be displayed in the UI.</param>
/// <param name="Value">The value to be stored.</param>
/// <typeparam name="T">The type of the value to store.</typeparam>
public readonly record struct ConfigurationSelectData<T>(string Name, T Value);
@@ -15,14 +15,6 @@ using WritingStylesEMail = AIStudio.Assistants.EMail.WritingStyles;
namespace AIStudio.Settings;
/// <summary>
/// A data structure to map a name to a value.
/// </summary>
/// <param name="Name">The name of the value, to be displayed in the UI.</param>
/// <param name="Value">The value to be stored.</param>
/// <typeparam name="T">The type of the value to store.</typeparam>
public readonly record struct ConfigurationSelectData<T>(string Name, T Value);
/// <summary>
/// A static factory class to get the lists of selectable values.
/// </summary>
@@ -63,11 +63,41 @@ public sealed class Data
/// </summary>
public Dictionary<string, ManagedEditableDefaultState> ManagedEditableDefaults { get; set; } = [];
/// <summary>
/// The configuration plugin that owns each locked managed setting.
/// </summary>
public Dictionary<string, Guid> ManagedLockedConfigurations { get; set; } = [];
/// <summary>
/// The value each managed setting had before a configuration plugin took it over, as JSON.
/// </summary>
/// <remarks>
/// A configuration plugin might be removed later, e.g. when a test configuration ends or when an
/// organization withdraws its configuration. The value the user had chosen before belongs to the
/// user, so we keep it here and restore it instead of falling back to the app's default value.
/// The snapshot is taken once, when a setting becomes managed, and is consumed when no
/// configuration plugin manages that setting anymore.
/// </remarks>
public Dictionary<string, string> ManagedUserValueSnapshots { get; set; } = [];
/// <summary>
/// Cached audit results for assistant plugins.
/// </summary>
public List<PluginAssistantAudit> AssistantPluginAudits { get; set; } = [];
/// <summary>
/// The assistant plugin hashes whose organization default for the activation was already applied.
/// </summary>
/// <remarks>
/// An organization may enable an assistant plugin it approved while still letting the user switch
/// it off again. That is a default, not a rule, so it must be applied exactly once: applying it on
/// every start would keep switching the assistant back on against the user's decision. We remember
/// the hashes it was applied for, and forget one as soon as no approval asks for it anymore, so a
/// later rollout of the same plugin takes effect again. Activations the user may not override are
/// not listed here: those are decided live and never touch the list of enabled plugins.
/// </remarks>
public List<string> AppliedEnterpriseAssistantActivations { get; set; } = [];
/// <summary>
/// The next provider number to use.
/// </summary>
@@ -119,6 +149,11 @@ public sealed class Data
public DataDocumentAnalysis DocumentAnalysis { get; init; } = new();
/// <summary>
/// Gets the managed Batch Processing Assistant defaults.
/// </summary>
public DataBatchProcessing BatchProcessing { get; init; } = new(x => x.BatchProcessing);
public DataMandatoryInformation MandatoryInformation { get; init; } = new();
public DataTextSummarizer TextSummarizer { get; init; } = new();
@@ -159,4 +194,6 @@ public sealed class Data
public DataBiasOfTheDay BiasOfTheDay { get; init; } = new();
public DataI18N I18N { get; init; } = new();
public DataTools Tools { get; init; } = new(x => x.Tools);
}
@@ -57,6 +57,11 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary>
public StartPage StartPage { get; set; } = ManagedConfiguration.Register(configSelection, n => n.StartPage, StartPage.HOME);
/// <summary>
/// Whether an alert dialog should be shown when prompt-injection content is blocked.
/// </summary>
public bool ShowPromptInjectionAlert { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShowPromptInjectionAlert, true);
/// <summary>
/// Should the built-in introduction be visible on the home page?
/// </summary>
@@ -148,7 +153,27 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// Should the user be allowed to add providers?
/// </summary>
public bool AllowUserToAddProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddProvider, true);
/// <summary>
/// Should the user be allowed to import plugin archives from disk?
/// </summary>
public bool AllowUserToImportPlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportPlugins, true);
/// <summary>
/// Should the user be allowed to import configuration plugin archives from disk?
/// </summary>
/// <remarks>
/// This is a second gate on top of AllowUserToImportPlugins, and both must allow the import.
/// Configuration plugins deserve their own switch because they are far more powerful than an
/// assistant: they define LLM providers and data sources, and they lock settings.
/// </remarks>
public bool AllowUserToImportConfigurationPlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportConfigurationPlugins, true);
/// <summary>
/// Should the user be allowed to share or export plugins as archives?
/// </summary>
public bool AllowUserToSharePlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToSharePlugins, true);
/// <summary>
/// Should administration settings be visible in the UI?
/// </summary>
@@ -10,4 +10,27 @@ public sealed class DataAssistantPluginEnterpriseApproval
public string Comment { get; init; } = string.Empty;
public string ApprovedBy { get; init; } = string.Empty;
public DateTimeOffset? ApprovedAtUtc { get; init; }
/// <summary>
/// Whether the organization wants this assistant plugin to be enabled, instead of leaving that
/// to the user.
/// </summary>
/// <remarks>
/// An approval only ever states that a plugin is safe. Enabling it is a separate decision, and
/// without this field it stays with the user: a rolled-out assistant is approved, but every
/// colleague still has to switch it on. This field is how an organization makes that decision
/// instead.
/// </remarks>
public bool Activate { get; init; }
/// <summary>
/// Whether the user may switch an assistant plugin the organization activated off again.
/// </summary>
/// <remarks>
/// This follows the AllowUserOverride convention of every managed setting: without it, what the
/// organization set is locked; with it, the organization only provides a default the user may
/// change. It has no meaning of its own while Activate is false, because there is nothing to
/// override then.
/// </remarks>
public bool AllowUserOverride { get; init; }
}
@@ -0,0 +1,72 @@
using System.Linq.Expressions;
using AIStudio.Assistants.BatchProcessing;
using AIStudio.Provider;
namespace AIStudio.Settings.DataModel;
/// <summary>
/// Stores managed defaults for the Batch Processing Assistant.
/// </summary>
/// <param name="configSelection">The managed-configuration selector.</param>
public sealed class DataBatchProcessing(Expression<Func<Data, DataBatchProcessing>>? configSelection = null)
{
public const string DEFAULT_FILE_PATTERNS = "*.pdf;*.docx;*.pptx;*.xlsx;*.md;*.txt;*.mp3;*.wav;*.wave;*.aac;*.flac;*.ogg;*.opus;*.m4a;*.m4b;*.wma;*.alac;*.aif;*.aiff;*.caf;*.mp4;*.m4v;*.avi;*.mkv;*.mov;*.wmv;*.flv;*.webm";
public const int MIN_DELAY_SECONDS = 6;
public const int MAX_DELAY_SECONDS = 300;
public const int DEFAULT_MIN_DELAY_SECONDS = 6;
public const int DEFAULT_MAX_DELAY_SECONDS = 10;
/// <summary>
/// Initializes an unmanaged Batch Processing settings instance.
/// </summary>
public DataBatchProcessing() : this(null)
{
}
public bool PreselectOptions { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectOptions, false);
public string InputDirectory { get; set; } = ManagedConfiguration.Register(configSelection, value => value.InputDirectory, string.Empty);
public string OutputDirectory { get; set; } = ManagedConfiguration.Register(configSelection, value => value.OutputDirectory, string.Empty);
public string FilePatterns { get; set; } = ManagedConfiguration.Register(configSelection, value => value.FilePatterns, DEFAULT_FILE_PATTERNS);
public bool IncludeSubdirectories { get; set; } = ManagedConfiguration.Register(configSelection, value => value.IncludeSubdirectories, false);
public BatchProcessingPromptSource PromptSource { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PromptSource, BatchProcessingPromptSource.FREE_PROMPT);
public string FreePrompt { get; set; } = ManagedConfiguration.Register(configSelection, value => value.FreePrompt, string.Empty);
public string PromptFilePath { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PromptFilePath, string.Empty);
public string PreselectedPolicyId { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedPolicyId, string.Empty);
public BatchProcessingOutputMode OutputMode { get; set; } = ManagedConfiguration.Register(configSelection, value => value.OutputMode, BatchProcessingOutputMode.INDIVIDUAL_FILES);
/// <summary>
/// The file format of the individual result files, one per processed document.
/// </summary>
/// <remarks>
/// Only formats which hold an entire answer, see FileExportFormatExtensions.ANSWER_FORMATS.
/// The tabular formats belong to the output mode TABLE_ONLY, which writes one table for the
/// whole run instead of one file per document.
/// </remarks>
public FileExportFormat ResultFileFormat { get; set; } = ManagedConfiguration.Register(configSelection, value => value.ResultFileFormat, FileExportFormat.MARKDOWN);
public string CsvFileName { get; set; } = ManagedConfiguration.Register(configSelection, value => value.CsvFileName, string.Empty);
public string ResultColumnHeader { get; set; } = ManagedConfiguration.Register(configSelection, value => value.ResultColumnHeader, string.Empty);
public BatchProcessingCsvSeparator CsvSeparator { get; set; } = ManagedConfiguration.Register(configSelection, value => value.CsvSeparator, BatchProcessingCsvSeparator.SEMICOLON);
public string CustomCsvSeparator { get; set; } = ManagedConfiguration.Register(configSelection, value => value.CustomCsvSeparator, string.Empty);
public int MinimumDelaySeconds { get; set; } = ManagedConfiguration.Register(configSelection, value => value.MinimumDelaySeconds, DEFAULT_MIN_DELAY_SECONDS);
public int MaximumDelaySeconds { get; set; } = DEFAULT_MAX_DELAY_SECONDS;
public ConfidenceLevel MinimumProviderConfidence { get; set; } = ManagedConfiguration.Register(configSelection, value => value.MinimumProviderConfidence, ConfidenceLevel.NONE);
public string PreselectedProvider { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedProvider, string.Empty);
}
@@ -57,6 +57,19 @@ public sealed record DataDocumentAnalysisPolicy : ConfigurationBaseObject
/// The minimum confidence level required for a provider to be considered.
/// </summary>
public ConfidenceLevel MinimumProviderConfidence { get; set; } = ConfidenceLevel.NONE;
/// <summary>
/// The tools this policy permits the model to use.
/// </summary>
/// <remarks>
/// A limit, not a preselection: a tool absent from this list cannot be chosen for a run of this
/// policy. Empty therefore means no tools at all, which is what a policy written before this
/// field existed gets — an analysis keeps working exactly as its author wrote it.<br/><br/>
/// This narrows what the user may pick; it never widens what a tool is allowed to do. Every
/// permitted tool still has to pass the provider confidence checks, so a tool demanding High
/// confidence stays out of reach of a weaker provider whether a policy lists it or not.
/// </remarks>
public HashSet<string> AllowedToolIds { get; set; } = [];
/// <summary>
/// Which LLM provider should be preselected?
@@ -130,6 +143,23 @@ public sealed record DataDocumentAnalysisPolicy : ConfigurationBaseObject
if (table.TryGetValue("HidePolicyDefinition", out var hideValue) && hideValue.TryRead<bool>(out var hide))
hidePolicyDefinition = hide;
//
// Unknown tool IDs are kept rather than rejected: an organization may roll out a policy
// before the plugin providing that tool reaches every workstation. A tool that does not
// exist simply never shows up, and the policy starts working once it does.
//
var allowedToolIds = new HashSet<string>(StringComparer.Ordinal);
if (table.TryGetValue("AllowedToolIds", out var toolIdsValue) && toolIdsValue.TryRead<LuaTable>(out var toolIdsTable))
{
for (var toolIdx = 1; toolIdx <= toolIdsTable.ArrayLength; toolIdx++)
{
if (toolIdsTable[toolIdx].TryRead<string>(out var toolId) && !string.IsNullOrWhiteSpace(toolId))
allowedToolIds.Add(toolId.Trim());
else
LOG.LogWarning("The configured document analysis policy {PolicyIndex} contains an invalid entry in its AllowedToolIds list.", idx);
}
}
policy = new DataDocumentAnalysisPolicy
{
Id = id.ToString(),
@@ -139,6 +169,7 @@ public sealed record DataDocumentAnalysisPolicy : ConfigurationBaseObject
AnalysisRules = analysisRules,
OutputRules = outputRules,
MinimumProviderConfidence = minimumConfidence,
AllowedToolIds = allowedToolIds,
PreselectedProvider = preselectedProvider,
PreselectedProfile = preselectedProfile,
HidePolicyDefinition = hidePolicyDefinition,
@@ -0,0 +1,67 @@
using System.Linq.Expressions;
namespace AIStudio.Settings.DataModel;
public sealed class DataTools(Expression<Func<Data, DataTools>>? configSelection = null)
{
public DataTools() : this(null)
{
}
/// <summary>
/// The settings the user entered per tool: tool ID, then field name.
/// </summary>
public Dictionary<string, Dictionary<string, string>> Settings { get; set; } = [];
public Dictionary<string, HashSet<string>> DefaultToolIdsByComponent { get; set; } = [];
public HashSet<string> VisibleToolSelectionComponents { get; set; } = [];
public bool EnableTools { get; set; } = ManagedConfiguration.Register(
configSelection,
x => x.EnableTools,
true);
public HashSet<string> DisabledToolIds { get; set; } = ManagedConfiguration.Register(
configSelection,
x => x.DisabledToolIds,
[]);
public Dictionary<string, string> MinimumProviderConfidenceByToolId { get; set; } = ManagedConfiguration.Register(
configSelection,
x => x.MinimumProviderConfidenceByToolId,
new Dictionary<string, string>(StringComparer.Ordinal));
/// <summary>
/// Tool settings an organization fixed, which the user cannot change. Keys are
/// "toolId.fieldName".
/// </summary>
/// <remarks>
/// Keyed by tool and field rather than held in a property per setting, because a property per
/// setting only works for the tools AI Studio ships. Tools defined by plugin authors are not
/// known at compile time, yet an organization has to be able to configure them the same way.
/// <br/><br/>
/// A secret field travels here too, but only encrypted with the enterprise secret, in the
/// same "ENC:v1:" form the providers use for their API keys. What is stored is therefore
/// ciphertext, worthless without a secret that lives outside every deployed file. A plaintext
/// secret is refused rather than used, and a secret is never accepted as a pre-filled default
/// — see the tool settings service for both rules.
/// </remarks>
public Dictionary<string, string> LockedToolSettings { get; set; } = ManagedConfiguration.Register(
configSelection,
x => x.LockedToolSettings,
new Dictionary<string, string>(StringComparer.Ordinal));
/// <summary>
/// Tool settings an organization pre-filled but left changeable. Keys are "toolId.fieldName".
/// </summary>
/// <remarks>
/// Applies until the user saves a value of their own, which then wins. That is the difference
/// to the locked settings above, and the reason both exist: an organization can fix the search
/// instance while leaving the timeouts to the user.
/// </remarks>
public Dictionary<string, string> DefaultToolSettings { get; set; } = ManagedConfiguration.Register(
configSelection,
x => x.DefaultToolSettings,
new Dictionary<string, string>(StringComparer.Ordinal));
}
@@ -125,6 +125,8 @@ public static class DataSourceSecurityTrustExtensions
public static bool IsTrustedByConfiguration(this TranscriptionProvider provider, SettingsManager settingsManager) => IsTrustedProviderId(provider.Id, settingsManager);
public static bool IsTrustedByConfiguration(this IProvider provider, SettingsManager settingsManager) => IsTrustedProviderId(provider.ConfiguredProviderId, settingsManager);
private static bool IsTrustedProviderId(string providerId, SettingsManager settingsManager)
{
if (string.IsNullOrWhiteSpace(providerId))
@@ -132,4 +134,4 @@ public static class DataSourceSecurityTrustExtensions
return settingsManager.ConfigurationData.DataSourceSecurity.TrustedProviderIds.Any(id => string.Equals(id, providerId, StringComparison.OrdinalIgnoreCase));
}
}
}
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Provider.HuggingFace;
using AIStudio.Tools.PluginSystem;
using SharedTools;
@@ -23,7 +24,10 @@ public sealed record EmbeddingProvider(
Host Host = Host.NONE,
string TokenizerPath = "",
int EmbeddingBatchSize = 0,
int TokenLimit = 0) : ConfigurationBaseObject, ISecretId
int TokenLimit = 0,
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "",
HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
public const int DEFAULT_TOKEN_LIMIT = 8192;
public const int DEFAULT_EMBEDDING_BATCH_SIZE = 1;
@@ -56,7 +60,7 @@ public sealed record EmbeddingProvider(
#endregion
public static bool TryParseEmbeddingProviderTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject provider)
public static bool TryParseEmbeddingProviderTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject provider)
{
provider = NONE;
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
@@ -122,6 +126,29 @@ public sealed record EmbeddingProvider(
embeddingBatchSize = DEFAULT_EMBEDDING_BATCH_SIZE;
}
var allowUserProvidedApiKey = false;
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var hfInferenceProvider = HFInferenceProvider.NONE;
if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead<string>(out var hfInferenceProviderText))
{
if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider))
{
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})");
hfInferenceProvider = HFInferenceProvider.NONE;
}
}
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
if (!iconPathValue.TryRead<string>(out var iconPath))
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid icon path. Falling back to the built-in provider icon. (Plugin ID: {configPluginId})");
else if (!PluginIconFile.TryLoadDataUrl(iconPath, pluginPath, out customIconDataUrl, out var iconIssue))
LOGGER.LogWarning($"The configured embedding provider {idx} contains an invalid icon path. Falling back to the built-in provider icon. Issue: {iconIssue} (Plugin ID: {configPluginId})");
}
provider = new EmbeddingProvider
{
Num = 0, // will be set later by the PluginConfigurationObject
@@ -137,10 +164,20 @@ public sealed record EmbeddingProvider(
TokenizerPath = tokenizerPath,
EmbeddingBatchSize = embeddingBatchSize,
TokenLimit = tokenLimit,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
HFInferenceProvider = hfInferenceProvider,
};
// Handle encrypted API key if present:
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
// Handle an encrypted API key if present. When the user manages their own key for this
// embedding provider, we must never enqueue an embedded key: doing so would overwrite the
// user's key in the OS keyring on every configuration reload.
if (allowUserProvidedApiKey)
{
if (table.TryGetValue("APIKey", out var ignoredApiKeyValue) && ignoredApiKeyValue.TryRead<string>(out var ignoredApiKeyText) && !string.IsNullOrWhiteSpace(ignoredApiKeyText))
LOGGER.LogWarning($"The configured embedding provider {idx} sets both AllowUserProvidedAPIKey and an embedded APIKey. Ignoring the embedded key: the user manages their own key for this provider. (Plugin ID: {configPluginId})");
}
else if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
{
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
@@ -196,6 +233,14 @@ public sealed record EmbeddingProvider(
/// <returns>A Lua configuration section string.</returns>
public string ExportAsConfigurationSection(string? encryptedApiKey = null)
{
var hfInferenceProviderLine = string.Empty;
if (this.HFInferenceProvider is not HFInferenceProvider.NONE)
{
hfInferenceProviderLine = $"""
["HFInferenceProvider"] = "{this.HFInferenceProvider}",
""";
}
var apiKeyLine = string.Empty;
if (!string.IsNullOrWhiteSpace(encryptedApiKey))
{
@@ -209,13 +254,14 @@ public sealed record EmbeddingProvider(
["Id"] = "{{Guid.NewGuid().ToString()}}",
["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}",
["UsedLLMProvider"] = "{{this.UsedLLMProvider}}",
["TokenizerPath"] = "{{this.TokenizerPath}}",
["TokenLimit"] = {{this.EffectiveTokenLimit}},
["EmbeddingBatchSize"] = {{this.EffectiveEmbeddingBatchSize}},
["Host"] = "{{this.Host}}",
["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}",
{{hfInferenceProviderLine}}
{{apiKeyLine}}
["Model"] = {
["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}",
@@ -83,6 +83,7 @@ public static partial class ManagedConfiguration
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
/// <param name="_">An unused parameter to help with type inference. You might ignore it when calling the method.</param>
/// <param name="validator">An optional validator for rejecting parsed values outside the setting's supported range.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
@@ -92,7 +93,8 @@ public static partial class ManagedConfiguration
Guid configPluginId,
LuaTable settings,
bool dryRun,
ISpanParsable<TValue>? _ = null)
ISpanParsable<TValue>? _ = null,
Func<TValue, bool>? validator = null)
where TValue : struct, ISpanParsable<TValue>
{
//
@@ -113,7 +115,8 @@ public static partial class ManagedConfiguration
if (configuredLuaValue.Type is LuaValueType.String && configuredLuaValue.TryRead<string>(out var configuredLuaValueText))
{
// Step 3 -- try to parse the string as the target type:
if (TValue.TryParse(configuredLuaValueText, CultureInfo.InvariantCulture, out var configuredParsedValue))
if (TValue.TryParse(configuredLuaValueText, CultureInfo.InvariantCulture, out var configuredParsedValue)
&& (validator?.Invoke(configuredParsedValue) ?? true))
{
configuredValue = configuredParsedValue;
successful = true;
@@ -121,7 +124,8 @@ public static partial class ManagedConfiguration
}
// Step 2b -- try to read the Lua value:
if(configuredLuaValue.TryRead<TValue>(out var configuredLuaValueInstance))
if(configuredLuaValue.TryRead<TValue>(out var configuredLuaValueInstance)
&& (validator?.Invoke(configuredLuaValueInstance) ?? true))
{
configuredValue = configuredLuaValueInstance;
successful = true;
@@ -654,6 +658,11 @@ public static partial class ManagedConfiguration
if (dryRun)
return successful;
//
// Contributions need no protection against a takeover: every configuration plugin has its
// own contribution, so no plugin can replace or drop the contribution of another one. This
// is also why a local configuration plugin may contribute next to one of an organization.
//
if (successful)
{
var configInstance = configSelection.Compile().Invoke(SettingsManagerAccess.ConfigurationData);
@@ -663,10 +672,8 @@ public static partial class ManagedConfiguration
configMeta.SetValue(merged);
configMeta.SetPluginContribution(new HashSet<TValue>(configuredValue), configPluginId);
}
else if (configMeta.HasPluginContribution && configMeta.PluginContributionByConfigPluginId == configPluginId)
{
configMeta.ClearPluginContribution();
}
else
configMeta.RemovePluginContribution(configPluginId);
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId == configPluginId)
configMeta.UnlockConfiguration();
@@ -768,7 +775,7 @@ public static partial class ManagedConfiguration
return false;
var successful = false;
var configuredValue = configMeta.Default;
var configuredValue = CloneStringDictionary(configMeta.Default);
// Step 1 -- try to read the Lua value (we expect a table) out of the Lua table:
if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) &&
@@ -805,7 +812,9 @@ public static partial class ManagedConfiguration
if(dryRun)
return successful;
return HandleParsedValue(configPluginId, dryRun, successful, configMeta, configuredValue);
var settingName = SettingName(propertyExpression);
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
return HandleParsedDictionaryValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
@@ -905,6 +914,18 @@ public static partial class ManagedConfiguration
if(dryRun)
return successful;
// The setting might belong to the IT department of an organization. In that case, no local
// configuration plugin may touch it, no matter what it declares:
if (!MayManageSetting(configPluginId, configMeta))
return false;
//
// Remember the value the user had chosen before any configuration plugin took this setting
// over. Once no plugin manages it anymore, we hand that value back to the user:
//
if (successful)
configMeta.CaptureUserValueSnapshot();
switch (successful)
{
case true:
@@ -924,8 +945,8 @@ public static partial class ManagedConfiguration
// case only when the setting was locked and managed by the same configuration plugin.
//
// The other case, when the setting was locked and managed by a different configuration plugin,
// is handled by the IsConfigurationLeftOver method, which checks if the configuration plugin
// is still available. If it is not available, it resets the locked state of the
// is handled by the CleanupLeftOverManagedConfigurations method, which checks if the configuration
// plugin is still available. If it is not available, it resets the locked state of the
// configuration setting, allowing it to be reconfigured by a different plugin or left unchanged.
//
configMeta.ResetLockedConfiguration();
@@ -954,6 +975,20 @@ public static partial class ManagedConfiguration
if (dryRun)
return successful;
// The setting might belong to the IT department of an organization. In that case, no local
// configuration plugin may touch it, no matter what it declares:
if (!MayManageSetting(configPluginId, configMeta))
return false;
//
// Remember the value the user had chosen before any configuration plugin took this setting
// over. Once no plugin manages it anymore, we hand that value back to the user. This has to
// happen before the managed state below changes, because only an unmanaged setting holds a
// value which belongs to the user:
//
if (successful)
configMeta.CaptureUserValueSnapshot();
switch (successful)
{
case true when managedMode is ManagedConfigurationMode.LOCKED:
@@ -992,6 +1027,67 @@ public static partial class ManagedConfiguration
configMeta.ResetLockedConfiguration();
break;
case false when configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT
&& TryGetEditableDefaultState(settingName, out var editableDefaultStateToRemove)
&& editableDefaultStateToRemove.ConfigPluginId == configPluginId:
configMeta.ResetEditableDefaultConfiguration(HasUserChangedEditableDefault(configMeta, editableDefaultStateToRemove));
ClearEditableDefaultState(settingName);
break;
}
return successful;
}
private static bool HandleParsedDictionaryValue<TClass>(
Guid configPluginId,
bool dryRun,
bool successful,
ConfigMeta<TClass, IDictionary<string, string>> configMeta,
IDictionary<string, string> configuredValue,
ManagedConfigurationMode managedMode,
string settingName)
{
if (dryRun)
return successful;
switch (successful)
{
case true when managedMode is ManagedConfigurationMode.LOCKED:
ClearEditableDefaultState(settingName);
configMeta.ClearEditableDefaultConfiguration();
configMeta.SetValue(CloneStringDictionary(configuredValue));
configMeta.LockConfiguration(configPluginId);
break;
case true when managedMode is ManagedConfigurationMode.EDITABLE_DEFAULT:
var currentValueSerialized = SerializeManagedStringDictionaryValue(configMeta.GetValue());
var configuredValueSerialized = SerializeManagedStringDictionaryValue(configuredValue);
string lastAppliedValue;
if (!TryGetEditableDefaultState(settingName, out var editableDefaultState))
{
configMeta.SetValue(CloneStringDictionary(configuredValue));
lastAppliedValue = configuredValueSerialized;
}
else
{
lastAppliedValue = editableDefaultState.LastAppliedValue;
if (string.Equals(currentValueSerialized, lastAppliedValue, StringComparison.Ordinal))
{
configMeta.SetValue(CloneStringDictionary(configuredValue));
lastAppliedValue = configuredValueSerialized;
}
}
SetEditableDefaultState(settingName, configPluginId, lastAppliedValue);
configMeta.UnlockConfiguration();
configMeta.SetEditableDefaultConfiguration(configPluginId);
break;
case false when configMeta.IsLocked && configMeta.LockedByConfigPluginId == configPluginId:
configMeta.ResetLockedConfiguration();
break;
case false when configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT
&& TryGetEditableDefaultState(settingName, out var editableDefaultStateToRemove)
&& editableDefaultStateToRemove.ConfigPluginId == configPluginId:
@@ -1020,7 +1116,7 @@ public static partial class ManagedConfiguration
return ManagedConfigurationMode.LOCKED;
}
private static string SerializeManagedScalarValue<TValue>(TValue value) => value switch
internal static string SerializeManagedScalarValue<TValue>(TValue value) => value switch
{
null => string.Empty,
string text => text,
@@ -1036,4 +1132,12 @@ public static partial class ManagedConfiguration
_ => value.ToString() ?? string.Empty,
};
}
private static Dictionary<string, string> CloneStringDictionary(IDictionary<string, string> values) => new(values, StringComparer.Ordinal);
private static string SerializeManagedStringDictionaryValue(IDictionary<string, string> values) => string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
}
@@ -19,10 +19,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>The default value.</returns>
public static TValue Register<TClass, TValue>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
TValue defaultValue)
public static TValue Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, TValue>> propertyExpression, TValue defaultValue)
where TValue : struct
{
// When called from the JSON deserializer by using the standard constructor,
@@ -57,10 +54,7 @@ public static partial class ManagedConfiguration
/// <param name="defaultValue">The default value to use when the setting is not configured.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>The default value.</returns>
public static string Register<TClass>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, string>> propertyExpression,
string defaultValue)
public static string Register<TClass>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, string>> propertyExpression, string defaultValue)
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
@@ -95,10 +89,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the elements in the list within the configuration class.</typeparam>
/// <returns>A list containing the default value.</returns>
public static List<TValue> Register<TClass, TValue>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
TValue defaultValue)
public static List<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, IList<TValue>>> propertyExpression, TValue defaultValue)
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
@@ -133,10 +124,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the elements within the property list.</typeparam>
/// <returns>The list of default values.</returns>
public static List<TValue> Register<TClass, TValue>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
IList<TValue> defaultValues)
public static List<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, IList<TValue>>> propertyExpression, IList<TValue> defaultValues)
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
@@ -170,10 +158,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the values within the set.</typeparam>
/// <returns>A set containing the default value.</returns>
public static HashSet<TValue> Register<TClass, TValue>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
TValue defaultValue)
public static HashSet<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, ISet<TValue>>> propertyExpression, TValue defaultValue)
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
@@ -208,10 +193,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TValue">The type of the elements in the collection associated with the configuration property.</typeparam>
/// <returns>A set containing the default values.</returns>
public static HashSet<TValue> Register<TClass, TValue>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
IList<TValue> defaultValues)
public static HashSet<TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, ISet<TValue>>> propertyExpression, IList<TValue> defaultValues)
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
@@ -246,10 +228,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TDict">>The type of the dictionary within the configuration class.</typeparam>
/// <returns>A dictionary containing the default values.</returns>
public static TDict Register<TClass, TDict>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, IDictionary<string, string>>> propertyExpression,
TDict defaultValues)
public static TDict Register<TClass, TDict>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, IDictionary<string, string>>> propertyExpression, TDict defaultValues)
where TDict : IDictionary<string, string>, new()
{
// When called from the JSON deserializer by using the standard constructor,
@@ -286,10 +265,7 @@ public static partial class ManagedConfiguration
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>A dictionary containing the default values.</returns>
public static Dictionary<TKey, TValue> Register<TClass, TKey, TValue>(
Expression<Func<Data, TClass>>? configSelection,
Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression,
Dictionary<TKey, TValue> defaultValues)
public static Dictionary<TKey, TValue> Register<TClass, TKey, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression, Dictionary<TKey, TValue> defaultValues)
where TKey : struct, Enum
where TValue : struct, Enum
{
@@ -9,7 +9,10 @@ namespace AIStudio.Settings;
public static partial class ManagedConfiguration
{
private static readonly ConcurrentDictionary<string, IConfig> METADATA = new();
private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static ILogger Log => Program.LOGGER_FACTORY.CreateLogger(nameof(ManagedConfiguration));
/// <summary>
/// Attempts to retrieve the configuration metadata for a given configuration selection and
@@ -28,15 +31,13 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
out ConfigMeta<TClass, TValue> configMeta)
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression, out ConfigMeta<TClass, TValue> configMeta)
where TValue : Enum
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, TValue> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
@@ -65,14 +66,12 @@ public static partial class ManagedConfiguration
/// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, string>> propertyExpression,
out ConfigMeta<TClass, string> configMeta)
public static bool TryGet<TClass>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, string>> propertyExpression, out ConfigMeta<TClass, string> configMeta)
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, string> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
@@ -104,16 +103,13 @@ public static partial class ManagedConfiguration
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
// ReSharper disable MethodOverloadWithOptionalParameter
public static bool TryGet<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
out ConfigMeta<TClass, TValue> configMeta,
ISpanParsable<TValue>? _ = null)
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression, out ConfigMeta<TClass, TValue> configMeta, ISpanParsable<TValue>? _ = null)
where TValue : struct, ISpanParsable<TValue>
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, TValue> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
@@ -143,14 +139,12 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
out ConfigMeta<TClass, IList<TValue>> configMeta)
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, IList<TValue>>> propertyExpression, out ConfigMeta<TClass, IList<TValue>> configMeta)
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, IList<TValue>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
@@ -178,14 +172,12 @@ public static partial class ManagedConfiguration
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
out ConfigMeta<TClass, ISet<TValue>> configMeta)
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, ISet<TValue>>> propertyExpression, out ConfigMeta<TClass, ISet<TValue>> configMeta)
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, ISet<TValue>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
@@ -212,14 +204,12 @@ public static partial class ManagedConfiguration
/// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IDictionary<string, string>>> propertyExpression,
out ConfigMeta<TClass, IDictionary<string, string>> configMeta)
public static bool TryGet<TClass>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, IDictionary<string, string>>> propertyExpression, out ConfigMeta<TClass, IDictionary<string, string>> configMeta)
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, IDictionary<string, string>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
@@ -248,16 +238,14 @@ public static partial class ManagedConfiguration
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TKey, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression,
out ConfigMeta<TClass, Dictionary<TKey, TValue>> configMeta)
public static bool TryGet<TClass, TKey, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression, out ConfigMeta<TClass, Dictionary<TKey, TValue>> configMeta)
where TKey : struct, Enum
where TValue : struct, Enum
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<TKey, TValue>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
@@ -270,211 +258,176 @@ public static partial class ManagedConfiguration
}
/// <summary>
/// Checks if a configuration setting is left over from a configuration plugin that is no longer available.
/// If the configuration setting is locked and managed by a configuration plugin that is not available,
/// it resets the managed state of the configuration setting and returns true.
/// Otherwise, it returns false.
/// Checks whether a configuration plugin may manage a setting, or whether that setting belongs
/// to the IT department of an organization.
/// </summary>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="availablePlugins">The collection of available plugins to check against.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration setting is left over and was reset, otherwise false.</returns>
public static bool IsConfigurationLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
IReadOnlyList<IAvailablePlugin> availablePlugins)
where TValue : Enum
/// <remarks>
/// A local configuration plugin must not take over a setting an organization manages. Otherwise,
/// anyone could hand out a configuration plugin that quietly replaces parts of the organization
/// configuration, e.g. the address of a self-hosted provider.<br/><br/>
/// Between two configuration plugins of the same organization, we do not interfere: both belong
/// to the IT department, so the one processed later wins, as before.
/// </remarks>
/// <param name="configPluginId">The configuration plugin which wants to manage the setting.</param>
/// <param name="configMeta">The configuration metadata of the setting.</param>
/// <returns>True when the plugin may manage this setting, otherwise false.</returns>
private static bool MayManageSetting(Guid configPluginId, ConfigMetaBase configMeta)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId != Guid.Empty && configMeta.IsLocked)
{
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
}
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins);
}
public static bool IsConfigurationLeftOver<TClass>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, string>> propertyExpression,
IReadOnlyList<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId != Guid.Empty && configMeta.IsLocked)
{
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
}
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins);
}
// ReSharper disable MethodOverloadWithOptionalParameter
public static bool IsConfigurationLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, TValue>> propertyExpression,
IReadOnlyList<IAvailablePlugin> availablePlugins,
ISpanParsable<TValue>? _ = null)
where TValue : struct, ISpanParsable<TValue>
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId != Guid.Empty && configMeta.IsLocked)
{
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
}
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins);
}
// ReSharper restore MethodOverloadWithOptionalParameter
public static bool IsConfigurationLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IList<TValue>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins.ToList());
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is not null)
return false;
configMeta.ResetLockedConfiguration();
return true;
}
public static bool IsConfigurationLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
{
configMeta.ResetLockedConfiguration();
var owningConfigPluginId = GetSettingOwner(configMeta);
if (owningConfigPluginId == Guid.Empty || owningConfigPluginId == configPluginId)
return true;
}
if (!PluginFactory.IsOrganizationConfigurationPlugin(owningConfigPluginId))
return true;
if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
return true;
Log.LogWarning($"The configuration plugin '{configPluginId}' tried to manage the setting '{configMeta.SettingName}', which is managed by the configuration plugin '{owningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.");
return false;
}
/// <summary>
/// Checks if a plugin contribution is left over from a configuration plugin that is no longer available.
/// If so, it clears the contribution and returns true.
/// Determines the configuration plugin which currently manages a setting, if any.
/// </summary>
public static bool IsPluginContributionLeftOver<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, ISet<TValue>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
private static Guid GetSettingOwner(ConfigMetaBase configMeta)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty)
return configMeta.LockedByConfigPluginId;
if (!configMeta.HasPluginContribution || configMeta.PluginContributionByConfigPluginId == Guid.Empty)
return false;
// The editable default is persisted as well, so we prefer it over the in-memory state:
if (TryGetEditableDefaultState(configMeta.SettingName, out var editableDefaultState) && editableDefaultState.ConfigPluginId != Guid.Empty)
return editableDefaultState.ConfigPluginId;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.PluginContributionByConfigPluginId);
if (plugin is null)
{
configMeta.ClearPluginContribution();
return true;
}
return false;
return configMeta.EditableDefaultByConfigPluginId;
}
public static bool IsConfigurationLeftOver<TClass>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, IDictionary<string, string>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
/// <summary>
/// Removes all managed states whose configuration plugin is not available anymore.
/// </summary>
/// <remarks>
/// This covers every registered setting, regardless of its type: locked settings, editable
/// defaults, and additive plugin contributions. Settings do not need to be listed anywhere for
/// this cleanup to work, so adding a new managed setting cannot be forgotten here.<br/><br/>
/// A locked setting whose plugin is gone is reset to its default value. That is intended: the
/// value belonged to the organization, not to the user, and the user might not be able to
/// change it at all.
/// </remarks>
/// <param name="availablePlugins">The collection of available plugins to check against.</param>
/// <param name="deployedEnterpriseConfigPluginIds">
/// The IDs of the configuration plugins which an organization deployed on this machine, including
/// those which could not be loaded. A deployed plugin was not removed, so its settings must stay
/// untouched.
/// </param>
/// <returns>True when at least one setting was changed, otherwise false.</returns>
public static bool CleanupLeftOverManagedConfigurations(IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
var wasChanged = false;
var registeredSettingNames = new HashSet<string>(StringComparer.Ordinal);
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (plugin is null)
foreach (var config in METADATA.Values)
{
configMeta.ResetLockedConfiguration();
return true;
}
if (config is not ConfigMetaBase configMeta)
continue;
return false;
}
registeredSettingNames.Add(configMeta.SettingName);
public static bool IsConfigurationLeftOver<TClass, TKey, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<TKey, TValue>>> propertyExpression,
IEnumerable<IAvailablePlugin> availablePlugins)
where TKey : struct, Enum
where TValue : struct, Enum
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
//
// Restore the persisted ownership first. Otherwise, we would not recognize a left-over
// lock when nobody has read this setting since the settings were loaded:
//
configMeta.RestoreLockedConfiguration();
if (configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT)
{
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.EditableDefaultByConfigPluginId);
if (plugin is null)
// Check the locked state:
if (configMeta.IsLocked && configMeta.LockedByConfigPluginId != Guid.Empty && !IsPluginPresent(configMeta.LockedByConfigPluginId, availablePlugins, deployedEnterpriseConfigPluginIds))
{
configMeta.ClearEditableDefaultConfiguration();
ClearEditableDefaultState(SettingName(propertyExpression));
return true;
Log.LogInformation($"Resetting the setting '{configMeta.SettingName}': it was locked by the configuration plugin '{configMeta.LockedByConfigPluginId}', which is not available anymore.");
configMeta.ResetLockedConfiguration();
wasChanged = true;
}
return false;
// Check the editable default state:
if (CleanupEditableDefaultState(configMeta, availablePlugins, deployedEnterpriseConfigPluginIds))
wasChanged = true;
// Check the additive plugin contributions. Every contributing plugin is checked on its
// own, so one removed plugin does not take the contributions of the others with it:
foreach (var contributingConfigPluginId in configMeta.ContributingConfigPluginIds.ToList())
{
if (contributingConfigPluginId != Guid.Empty && IsPluginPresent(contributingConfigPluginId, availablePlugins, deployedEnterpriseConfigPluginIds))
continue;
Log.LogInformation($"Clearing the contribution of the configuration plugin '{contributingConfigPluginId}' to the setting '{configMeta.SettingName}': the plugin is not available anymore.");
configMeta.RemovePluginContribution(contributingConfigPluginId);
wasChanged = true;
}
//
// Finally, drop any snapshot of the user's value which nobody claims anymore. Without
// this, a setting which stopped being managed outside of the paths above would keep its
// snapshot in the settings file forever. The persisted editable default counts as a
// claim as well: it survives a configuration plugin which is deployed but could not be
// loaded, and that plugin is still in charge:
//
if (configMeta.ManagedMode is null && !TryGetEditableDefaultState(configMeta.SettingName, out _) && configMeta.ClearUserValueSnapshot())
{
Log.LogInformation($"Dropping the snapshot of the user's value for the setting '{configMeta.SettingName}': no configuration plugin manages it anymore.");
wasChanged = true;
}
}
if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
// Remove persisted states which belong to settings that do not exist anymore:
if (RemoveUnknownManagedStates(registeredSettingNames))
wasChanged = true;
var lockedPlugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId);
if (lockedPlugin is null)
{
configMeta.ResetLockedConfiguration();
return true;
}
return false;
return wasChanged;
}
/// <summary>
/// Checks whether a configuration plugin is still present on this machine.
/// </summary>
/// <remarks>
/// A plugin counts as present when it was loaded, or when it is deployed but could not be loaded.
/// The latter matters for organizations: a broken configuration plugin is still in charge, so we
/// must not treat its settings as left over.
/// </remarks>
private static bool IsPluginPresent(Guid configPluginId, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds) => deployedEnterpriseConfigPluginIds.Contains(configPluginId) || availablePlugins.Any(x => x.Id == configPluginId);
/// <summary>
/// Removes persisted managed states which belong to settings that are not registered anymore.
/// </summary>
/// <remarks>
/// Without this, states of removed or renamed settings would stay in the settings file forever.
/// </remarks>
private static bool RemoveUnknownManagedStates(IReadOnlySet<string> registeredSettingNames)
{
var wasChanged = false;
var configurationData = SettingsManagerAccess.ConfigurationData;
foreach (var settingName in configurationData.ManagedLockedConfigurations.Keys.Where(x => !registeredSettingNames.Contains(x)).ToList())
{
Log.LogInformation($"Removing the persisted lock of the setting '{settingName}': this setting does not exist anymore.");
configurationData.ManagedLockedConfigurations.Remove(settingName);
wasChanged = true;
}
foreach (var settingName in configurationData.ManagedEditableDefaults.Keys.Where(x => !registeredSettingNames.Contains(x)).ToList())
{
Log.LogInformation($"Removing the persisted editable default of the setting '{settingName}': this setting does not exist anymore.");
configurationData.ManagedEditableDefaults.Remove(settingName);
wasChanged = true;
}
foreach (var settingName in configurationData.ManagedUserValueSnapshots.Keys.Where(x => !registeredSettingNames.Contains(x)).ToList())
{
Log.LogInformation($"Removing the snapshot of the user's value for the setting '{settingName}': this setting does not exist anymore.");
configurationData.ManagedUserValueSnapshots.Remove(settingName);
wasChanged = true;
}
return wasChanged;
}
private static string Path<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression)
{
var className = typeof(TClass).Name;
@@ -507,25 +460,32 @@ public static partial class ManagedConfiguration
private static bool ClearEditableDefaultState(string settingName) => SettingsManagerAccess.ConfigurationData.ManagedEditableDefaults.Remove(settingName);
private static bool CleanupEditableDefaultState<TClass, TValue>(
ConfigMeta<TClass, TValue> configMeta,
string settingName,
IReadOnlyList<IAvailablePlugin> availablePlugins)
private static bool CleanupEditableDefaultState(ConfigMetaBase configMeta, IReadOnlyCollection<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds)
{
if (!TryGetEditableDefaultState(settingName, out var editableDefaultState))
if (!TryGetEditableDefaultState(configMeta.SettingName, out var editableDefaultState))
{
if (configMeta.ManagedMode is not ManagedConfigurationMode.EDITABLE_DEFAULT)
return false;
configMeta.ClearEditableDefaultConfiguration();
configMeta.ResetEditableDefaultConfiguration(keepCurrentValue: false);
return true;
}
var plugin = availablePlugins.FirstOrDefault(x => x.Id == editableDefaultState.ConfigPluginId);
if (plugin is not null)
if (IsPluginPresent(editableDefaultState.ConfigPluginId, availablePlugins, deployedEnterpriseConfigPluginIds))
return false;
configMeta.ClearEditableDefaultConfiguration();
return ClearEditableDefaultState(settingName);
Log.LogInformation($"Clearing the editable default of the setting '{configMeta.SettingName}': the configuration plugin '{editableDefaultState.ConfigPluginId}' is not available anymore.");
configMeta.ResetEditableDefaultConfiguration(HasUserChangedEditableDefault(configMeta, editableDefaultState));
return ClearEditableDefaultState(configMeta.SettingName);
}
/// <summary>
/// Checks whether the user has changed an editable default themselves.
/// </summary>
/// <remarks>
/// The user may change an editable default at any time. When the current value is not the one
/// the configuration plugin applied last, the user decided against that value, and their
/// decision outlives the plugin.
/// </remarks>
private static bool HasUserChangedEditableDefault(ConfigMetaBase configMeta, ManagedEditableDefaultState editableDefaultState) => !string.Equals(configMeta.SerializeCurrentValue(), editableDefaultState.LastAppliedValue, StringComparison.Ordinal);
}
@@ -0,0 +1,125 @@
namespace AIStudio.Settings;
/// <summary>
/// Loads an icon file a configuration plugin points to, such as a custom provider logo.
/// </summary>
/// <remarks>
/// The checks here are about the path, not about the markup: they keep a plugin from turning an
/// arbitrary file somewhere on the system into a data URL. Whether the file is a usable SVG, and
/// how it becomes a data URL, is up to SvgIcon.
/// </remarks>
internal static class PluginIconFile
{
public static bool TryLoadDataUrl(string iconPath, string pluginPath, out string dataUrl, out string issue)
{
dataUrl = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(iconPath))
{
issue = "The icon path is empty.";
return false;
}
if (Path.IsPathFullyQualified(iconPath))
{
issue = "The icon path must be relative to the configuration plugin directory.";
return false;
}
if (string.IsNullOrWhiteSpace(pluginPath))
{
issue = "The icon path cannot be resolved because the configuration plugin directory is unknown.";
return false;
}
var relativePath = iconPath
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
if (relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
{
issue = "The icon path must not contain '..' path segments.";
return false;
}
string pluginRoot;
string resolvedPath;
try
{
pluginRoot = Path.GetFullPath(pluginPath);
resolvedPath = Path.GetFullPath(Path.Combine(pluginRoot, relativePath));
}
catch (Exception e)
{
issue = $"The icon path is invalid: {e.Message}";
return false;
}
if (!IsInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path points outside of the configuration plugin directory.";
return false;
}
if (!string.Equals(Path.GetExtension(resolvedPath), ".svg", StringComparison.OrdinalIgnoreCase))
{
issue = "The icon file must use the .svg extension.";
return false;
}
if (!File.Exists(resolvedPath))
{
issue = "The icon file does not exist.";
return false;
}
try
{
// Check the size before reading, so an oversized file never reaches memory:
var fileInfo = new FileInfo(resolvedPath);
if (fileInfo.Length is <= 0 or > SvgIcon.MAX_ICON_SIZE_BYTES)
{
issue = $"The icon file must be between 1 byte and {SvgIcon.MAX_ICON_SIZE_BYTES / 1024} KiB.";
return false;
}
if (!LinksStayInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path contains a link that points outside of the configuration plugin directory.";
return false;
}
return SvgIcon.TryCreateDataUrl(File.ReadAllBytes(resolvedPath), out dataUrl, out issue);
}
catch (Exception e)
{
issue = $"The icon file could not be read: {e.Message}";
return false;
}
}
private static bool LinksStayInsideDirectory(string rootDirectory, string filePath)
{
var relativePath = Path.GetRelativePath(rootDirectory, filePath);
var currentPath = Path.GetFullPath(rootDirectory);
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
{
currentPath = Path.Combine(currentPath, segment);
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
var finalTarget = pathInfo.ResolveLinkTarget(true);
if (finalTarget is not null && !IsInsideDirectory(rootDirectory, finalTarget.FullName))
return false;
}
return true;
}
private static bool IsInsideDirectory(string rootDirectory, string path)
{
var root = Path.GetFullPath(rootDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var target = Path.GetFullPath(path);
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return target.StartsWith(root, comparison);
}
}
+30 -4
View File
@@ -21,6 +21,8 @@ namespace AIStudio.Settings;
/// <param name="IsSelfHosted">Whether the provider is self-hosted.</param>
/// <param name="Hostname">The hostname of the provider. Useful for self-hosted providers.</param>
/// <param name="Model">The LLM model to use for chat.</param>
/// <param name="AllowUserProvidedAPIKey">When set by a configuration plugin, the user may set their own API key for this otherwise locked, enterprise-managed provider.</param>
/// <param name="CustomIconDataUrl">The validated custom SVG icon supplied by a configuration plugin.</param>
public sealed record Provider(
uint Num,
string Id,
@@ -35,7 +37,9 @@ public sealed record Provider(
HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE,
string AdditionalJsonApiParameters = "",
string TokenizerPath = "",
ProviderCapabilityOverrides? CapabilityOverrides = null) : ConfigurationBaseObject, ISecretId
ProviderCapabilityOverrides? CapabilityOverrides = null,
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger<Provider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<Provider>();
@@ -84,7 +88,7 @@ public sealed record Provider(
#endregion
public static bool TryParseProviderTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject provider)
public static bool TryParseProviderTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject provider)
{
provider = NONE;
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
@@ -154,6 +158,19 @@ public sealed record Provider(
}
var capabilityOverrides = ProviderCapabilityOverrides.TryParseFromLuaTable(idx, table, configPluginId, LOGGER);
var allowUserProvidedApiKey = false;
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
if (!iconPathValue.TryRead<string>(out var iconPath))
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid icon path. Falling back to the built-in provider icon. (Plugin ID: {configPluginId})");
else if (!PluginIconFile.TryLoadDataUrl(iconPath, pluginPath, out customIconDataUrl, out var iconIssue))
LOGGER.LogWarning($"The configured provider {idx} contains an invalid icon path. Falling back to the built-in provider icon. Issue: {iconIssue} (Plugin ID: {configPluginId})");
}
provider = new Provider
{
Num = 0, // will be set later by the PluginConfigurationObject
@@ -170,10 +187,19 @@ public sealed record Provider(
AdditionalJsonApiParameters = additionalJsonApiParameters,
TokenizerPath = tokenizerPath,
CapabilityOverrides = capabilityOverrides,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
};
// Handle encrypted API key if present:
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
// Handle an encrypted API key if present. When the user manages their own key for this
// provider, we must never enqueue an embedded key: doing so would overwrite the user's
// key in the OS keyring on every configuration reload.
if (allowUserProvidedApiKey)
{
if (table.TryGetValue("APIKey", out var ignoredApiKeyValue) && ignoredApiKeyValue.TryRead<string>(out var ignoredApiKeyText) && !string.IsNullOrWhiteSpace(ignoredApiKeyText))
LOGGER.LogWarning($"The configured provider {idx} sets both AllowUserProvidedAPIKey and an embedded APIKey. Ignoring the embedded key: the user manages their own key for this provider. (Plugin ID: {configPluginId})");
}
else if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
{
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
@@ -18,6 +18,7 @@ public sealed record ProviderCapabilityOverrides
private static readonly IReadOnlyList<Capability> SUPPORTED_CAPABILITIES =
[
Capability.AUDIO_INPUT,
Capability.FUNCTION_CALLING,
Capability.MULTIPLE_IMAGE_INPUT,
Capability.SPEECH_INPUT,
Capability.VIDEO_INPUT,
@@ -30,6 +31,10 @@ public sealed record ProviderCapabilityOverrides
[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; }
@@ -57,6 +62,7 @@ public sealed record ProviderCapabilityOverrides
[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 ||
@@ -67,6 +73,7 @@ public sealed record ProviderCapabilityOverrides
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,
@@ -79,6 +86,7 @@ public sealed record ProviderCapabilityOverrides
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 },
@@ -47,6 +47,43 @@ public static partial class ProviderExtensions
Capability.CHAT_COMPLETION_API,
];
// Check for the Qwen 3.8 family:
if(modelName.StartsWith("qwen3.8"))
{
// Flash thinks by default, but thinking can be turned off:
if(modelName.StartsWith("qwen3.8-flash"))
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Unlike the open-weight checkpoint, the Max model keeps its vision
// capabilities when used through Alibaba Cloud:
if(modelName.StartsWith("qwen3.8-max"))
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// All other 3.8 models, such as the 27B one:
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
// Check for the 3.0 VL models:
if(modelName.IndexOf("-vl-") is not -1)
return
@@ -18,6 +18,26 @@ public static partial class ProviderExtensions
Capability.CHAT_COMPLETION_API,
];
// Claude Opus 5 and Sonnet 5 think adaptively unless thinking is turned off:
if(modelName.StartsWith("claude-opus-5") || modelName.StartsWith("claude-sonnet-5"))
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Claude Haiku 4.5 needs an explicit thinking budget to reason:
if(modelName.StartsWith("claude-haiku-4-5"))
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Claude 4.x models:
if(modelName.StartsWith("claude-opus-4") || modelName.StartsWith("claude-sonnet-4"))
return [
@@ -48,9 +68,10 @@ public static partial class ProviderExtensions
Capability.CHAT_COMPLETION_API,
];
// Any other model is able to process text only:
// Any other model. Every current Claude model accepts images, so we assume the
// same for models we do not know yet:
return [
Capability.TEXT_INPUT,
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
@@ -8,21 +8,31 @@ public static partial class ProviderExtensions
{
var modelName = model.Id.ToLowerInvariant().AsSpan();
// The reasoner alias points to the thinking mode of the current flash model:
if(modelName.IndexOf("reasoner") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
// The chat alias points to the non-thinking mode of the same model:
if(modelName.IndexOf("chat") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// DeepSeek publishes its models as open weights and offers them under the same
// names here. Instead of maintaining a second copy of those rules, we reuse the
// ones for open source models:
return GetModelCapabilitiesOpenSource(model);
}
}
@@ -0,0 +1,81 @@
using AIStudio.Provider;
namespace AIStudio.Settings;
public static partial class ProviderExtensions
{
/// <summary>
/// Determines the capabilities of a model offered through a gateway.
/// </summary>
/// <remarks>
/// A gateway serves the models of many other providers rather than models of its own. OpenRouter,
/// LiteLLM, and the Hugging Face router all work that way, and all three name their models the
/// same: "vendor/model-name".
/// </remarks>
/// <param name="model">The model as the gateway names it.</param>
/// <returns>The capabilities of the model when reached through a gateway.</returns>
private static List<Capability> GetModelCapabilitiesGateway(Model model)
{
//
// Model IDs follow the pattern "vendor/model-name". Examples:
// - openai/gpt-5.6
// - anthropic/claude-opus-5
// - google/gemini-3.7-flash
// - qwen/qwen3.8-flash-next
//
// A gateway offers the models of all the other providers. Instead of keeping a
// second set of rules here, which would always lag behind, we hand the model
// over to the provider implementation which already knows it. The vendor prefix
// has to be removed first: some of those implementations match the beginning of
// the model name and would not recognize a prefixed ID.
//
var separatorIndex = model.Id.IndexOf('/');
var vendor = separatorIndex is -1 ? string.Empty : model.Id[..separatorIndex].ToLowerInvariant();
var bareModel = separatorIndex is -1 ? model : model with { Id = model.Id[(separatorIndex + 1)..] };
var bareModelName = bareModel.Id.ToLowerInvariant().AsSpan();
var capabilities = vendor switch
{
// The gpt-oss models are open weights. The OpenAI implementation does not
// know them, because they are not part of the OpenAI cloud offering:
"openai" when bareModelName.IndexOf("gpt-oss") is not -1 => GetModelCapabilitiesOpenSource(bareModel),
"openai" => GetModelCapabilitiesOpenAI(bareModel),
"anthropic" => GetModelCapabilitiesAnthropic(bareModel),
// Gemma is open weights, Gemini is not:
"google" when bareModelName.IndexOf("gemma") is not -1 => GetModelCapabilitiesOpenSource(bareModel),
"google" => GetModelCapabilitiesGoogle(bareModel),
"mistralai" => GetModelCapabilitiesMistral(bareModel),
"perplexity" => GetModelCapabilitiesPerplexity(bareModel),
// Everything else is open source: Qwen, Llama, GLM, Kimi, Muse, Hunyuan,
// Nemotron, Grok, and whatever a gateway adds next. DeepSeek belongs here
// as well: its own implementation covers the aliases of the DeepSeek
// platform, while the gateways use the names of the open weights.
_ => GetModelCapabilitiesOpenSource(bareModel),
};
return NormalizeForGateway(capabilities);
}
/// <summary>
/// Adjusts the capabilities reported by another provider for use through a gateway.
/// </summary>
/// <param name="capabilities">The capabilities as reported by the provider implementation.</param>
/// <returns>The capabilities as they apply when using the model through a gateway.</returns>
/// <remarks>
/// A gateway serves every model through its OpenAI-compatible chat completion API.
/// The Responses API is not available there, no matter which API the original
/// provider offers.
/// </remarks>
private static List<Capability> NormalizeForGateway(List<Capability> capabilities)
{
capabilities.Remove(Capability.RESPONSES_API);
if(!capabilities.Contains(Capability.CHAT_COMPLETION_API))
capabilities.Add(Capability.CHAT_COMPLETION_API);
return capabilities;
}
}
@@ -10,14 +10,13 @@ public static partial class ProviderExtensions
if (modelName.IndexOf("gemini-") is not -1)
{
// Chat-compatible Gemini 3.x reasoning models:
if (modelName is "gemini-3.5-flash" ||
// Chat-compatible Gemini 3.x reasoning models. We match the entire 3.x line
// so that new releases are covered as well: they all reason, and the
// thinking level can only be lowered, never turned off. The two rolling
// aliases carry no version number and are listed separately:
if (modelName.IndexOf("gemini-3") is not -1 ||
modelName is "gemini-flash-latest" ||
modelName is "gemini-3.1-flash-lite" ||
modelName is "gemini-3-flash-preview" ||
modelName is "gemini-pro-latest" ||
modelName is "gemini-3.1-pro-preview" ||
modelName is "gemini-3.1-pro-preview-customtools")
modelName is "gemini-pro-latest")
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.AUDIO_INPUT,
@@ -4,100 +4,79 @@ namespace AIStudio.Settings;
public static partial class ProviderExtensions
{
//
// Mistral names its models after the month they were released: mistral-large-2512 is
// Mistral Large 3 from December 2025. The version number lives in the marketing name only,
// so matching on it misses nearly every model the API actually serves. The constants below
// read as YYMM and say from which release on a family gained a capability.
//
private const int MISTRAL_LARGE_VISION_SINCE = 2512; // Mistral Large 3
private const int MISTRAL_LARGE_REASONING_SINCE = 2512; // Mistral Large 3
private const int MISTRAL_MEDIUM_VISION_SINCE = 2505; // Mistral Medium 3
private const int MISTRAL_MEDIUM_REASONING_SINCE = 2604; // Mistral Medium 3.5
private const int MISTRAL_SMALL_VISION_SINCE = 2503; // Mistral Small 3.1
private const int MISTRAL_SMALL_REASONING_SINCE = 2603; // Mistral Small 4
private const int MINISTRAL_VISION_SINCE = 2512; // Ministral 3
/// <summary>
/// Used for families which have no reasoning at all. No release date can ever reach it.
/// </summary>
private const int MISTRAL_REASONING_NEVER = int.MaxValue;
//
// Where the "latest" aliases point to. Mistral moves them on with every release, so they
// have to behave like the release they resolve to instead of carrying their own rules.
//
private const int MISTRAL_LARGE_LATEST = 2512;
private const int MISTRAL_MEDIUM_LATEST = 2604;
private const int MISTRAL_SMALL_LATEST = 2603;
private const int MINISTRAL_LATEST = 2512;
/// <summary>
/// Mistral released its first date-named model in 2023. Anything below that is not a release
/// date but a parameter count or a context size which happens to have four digits.
/// </summary>
private const int MISTRAL_FIRST_RELEASE_YEAR = 23;
//
// Mistral serves some models under their marketing version as well, and it writes the version
// separator both ways: mistral-medium-3.5 and mistral-medium-3-5 are the same model. Those
// names carry no release date, so we map them onto the release they stand for. The order
// matters: the more specific version has to come first, otherwise "3" would swallow "3.5".
//
private static readonly (string VersionName, int ReleaseDate)[] MISTRAL_VERSION_NAMES =
[
("mistral-large-3", 2512),
("mistral-medium-3.5", 2604),
("mistral-medium-3-5", 2604),
("mistral-medium-3.1", 2508),
("mistral-medium-3-1", 2508),
("mistral-medium-3", 2505),
("mistral-small-4", 2603),
("mistral-small-3.2", 2506),
("mistral-small-3-2", 2506),
("mistral-small-3.1", 2503),
("mistral-small-3-1", 2503),
("mistral-small-3", 2501),
];
private static List<Capability> GetModelCapabilitiesMistral(Model model)
{
var modelName = model.Id.ToLowerInvariant().AsSpan();
// Pixtral models are able to do process images:
if (modelName.IndexOf("pixtral") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Mistral large latest:
if (modelName.IndexOf("mistral-large-latest") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Mistral large:
if (modelName.IndexOf("mistral-large-") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Mistral medium latest:
if (modelName.IndexOf("mistral-medium-latest") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Mistral medium:
if (modelName.IndexOf("mistral-medium-") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Mistral small latest:
if (modelName.IndexOf("mistral-small-latest") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Mistral small:
if (modelName.IndexOf("mistral-small-") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Mistral saba:
if (modelName.IndexOf("mistral-saba-") is not -1)
return
@@ -106,8 +85,107 @@ public static partial class ProviderExtensions
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
//
// The four families Mistral versions by release date. Ministral has to be matched before
// the others, although its name does not contain "mistral" as a substring: keeping the
// families together makes the block easier to read.
//
if (modelName.IndexOf("ministral") is not -1)
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MINISTRAL_LATEST), MINISTRAL_VISION_SINCE, MISTRAL_REASONING_NEVER);
if (modelName.IndexOf("mistral-large") is not -1)
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MISTRAL_LARGE_LATEST), MISTRAL_LARGE_VISION_SINCE, MISTRAL_LARGE_REASONING_SINCE);
if (modelName.IndexOf("mistral-medium") is not -1)
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MISTRAL_MEDIUM_LATEST), MISTRAL_MEDIUM_VISION_SINCE, MISTRAL_MEDIUM_REASONING_SINCE);
if (modelName.IndexOf("mistral-small") is not -1)
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MISTRAL_SMALL_LATEST), MISTRAL_SMALL_VISION_SINCE, MISTRAL_SMALL_REASONING_SINCE);
// Default:
return GetModelCapabilitiesOpenSource(model);
}
/// <summary>
/// Determines the release date a Mistral model belongs to.
/// </summary>
/// <param name="modelName">The lowercase model name to inspect.</param>
/// <param name="latestReleaseDate">The release the family's "latest" alias points to.</param>
/// <returns>The release date as YYMM, or 0 when the name carries none.</returns>
private static int GetMistralReleaseDate(ReadOnlySpan<char> modelName, int latestReleaseDate)
{
// The "latest" alias always points to the newest release of its family:
if (modelName.IndexOf("-latest") is not -1)
return latestReleaseDate;
foreach (var (versionName, releaseDate) in MISTRAL_VERSION_NAMES)
if (modelName.IndexOf(versionName) is not -1)
return releaseDate;
return ReadMistralReleaseDate(modelName);
}
/// <summary>
/// Reads the four-digit release date out of a Mistral model name.
/// </summary>
/// <remarks>
/// The block has to be exactly four digits long and has to read as a plausible year and month.
/// Without that, the size of a model would be mistaken for its release: ministral-14b-2512
/// must resolve to 2512 and not to anything the "14b" part could be read as.
/// </remarks>
/// <param name="modelName">The lowercase model name to inspect.</param>
/// <returns>The release date as YYMM, or 0 when the name carries none.</returns>
private static int ReadMistralReleaseDate(ReadOnlySpan<char> modelName)
{
for (var index = 0; index + 4 <= modelName.Length; index++)
{
// A digit next to the block means the block is longer than four digits:
if (index > 0 && char.IsAsciiDigit(modelName[index - 1]))
continue;
if (index + 4 < modelName.Length && char.IsAsciiDigit(modelName[index + 4]))
continue;
var candidate = modelName.Slice(index, 4);
if (!char.IsAsciiDigit(candidate[0]) || !char.IsAsciiDigit(candidate[1]) ||
!char.IsAsciiDigit(candidate[2]) || !char.IsAsciiDigit(candidate[3]))
continue;
var releaseDate = int.Parse(candidate);
var year = releaseDate / 100;
var month = releaseDate % 100;
if (year < MISTRAL_FIRST_RELEASE_YEAR || month is < 1 or > 12)
continue;
return releaseDate;
}
return 0;
}
/// <summary>
/// Builds the capabilities of a Mistral model from its release date.
/// </summary>
/// <remarks>
/// A model whose release date we cannot read gets neither image input nor reasoning. That is
/// the safe direction: offering an ability the model does not have would fail the request,
/// whereas a missing one can be added by hand through the capability overrides.
/// </remarks>
/// <param name="releaseDate">The release date of the model as YYMM, or 0 when unknown.</param>
/// <param name="visionSince">The release from which this family accepts images.</param>
/// <param name="reasoningSince">The release from which this family can reason.</param>
/// <returns>The capabilities of the model.</returns>
private static List<Capability> BuildMistralCapabilities(int releaseDate, int visionSince, int reasoningSince)
{
List<Capability> capabilities = [Capability.TEXT_INPUT, Capability.FUNCTION_CALLING, Capability.CHAT_COMPLETION_API, Capability.TEXT_OUTPUT];
if (releaseDate >= visionSince)
capabilities.Add(Capability.MULTIPLE_IMAGE_INPUT);
if (releaseDate >= reasoningSince)
capabilities.Add(Capability.OPTIONAL_REASONING);
return capabilities;
}
}
@@ -182,7 +182,7 @@ public static partial class ProviderExtensions
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT, Capability.IMAGE_OUTPUT,
Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING, Capability.REASONING_BY_DEFAULT,
Capability.FUNCTION_CALLING, Capability.REASONING_BY_DEFAULT,
Capability.WEB_SEARCH,
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
];
@@ -193,7 +193,7 @@ public static partial class ProviderExtensions
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING, Capability.REASONING_BY_DEFAULT,
Capability.FUNCTION_CALLING, Capability.REASONING_BY_DEFAULT,
Capability.WEB_SEARCH,
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
];
@@ -1,249 +0,0 @@
using AIStudio.Provider;
namespace AIStudio.Settings;
public static partial class ProviderExtensions
{
private static List<Capability> GetModelCapabilitiesOpenRouter(Model model)
{
var modelName = model.Id.ToLowerInvariant().AsSpan();
//
// OpenRouter model IDs follow the pattern: "provider/model-name"
// Examples:
// - openai/gpt-4o
// - anthropic/claude-3-5-sonnet
// - google/gemini-pro-1.5
// - meta-llama/llama-3.1-405b-instruct
//
// We need to detect capabilities based on both provider and model name.
//
//
// OpenAI models via OpenRouter:
//
if (modelName.IndexOf("openai/") is not -1)
{
// Reasoning models (o1, o3, o4 series)
if (modelName.IndexOf("/o1") is not -1 ||
modelName.IndexOf("/o3") is not -1 ||
modelName.IndexOf("/o4") is not -1)
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING,
Capability.CHAT_COMPLETION_API,
];
// GPT-4o and GPT-5 series with multimodal
if (modelName.IndexOf("/gpt-4o") is not -1 ||
modelName.IndexOf("/gpt-5") is not -1 ||
modelName.IndexOf("/chatgpt-4o") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Standard GPT-4
if (modelName.IndexOf("/gpt-4") is not -1)
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// GPT-3.5
if (modelName.IndexOf("/gpt-3.5") is not -1 ||
modelName.IndexOf("/gpt-3") is not -1)
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
//
// Anthropic models via OpenRouter:
//
if (modelName.IndexOf("anthropic/") is not -1)
{
// Claude 3.5 and newer with vision
if (modelName.IndexOf("/claude-3.5") is not -1 ||
modelName.IndexOf("/claude-3-5") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Claude 3 Opus/Sonnet with vision
if (modelName.IndexOf("/claude-3-opus") is not -1 ||
modelName.IndexOf("/claude-3-sonnet") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Other Claude 3 models
if (modelName.IndexOf("/claude-3") is not -1)
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
//
// Google models via OpenRouter:
//
if (modelName.IndexOf("google/") is not -1)
{
// Gemini models with multimodal
if (modelName.IndexOf("/gemini") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
// xAI Grok models via OpenRouter:
//
if (modelName.IndexOf("x-ai/") is not -1 || modelName.IndexOf("/grok") is not -1)
{
if (modelName.IndexOf("-vision") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
// DeepSeek models via OpenRouter:
//
if (modelName.IndexOf("/deepseek") is not -1)
{
if (modelName.IndexOf("-r1") is not -1 || modelName.IndexOf(" r1") is not -1)
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING,
Capability.CHAT_COMPLETION_API,
];
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
//
// Mistral models via OpenRouter:
//
if (modelName.IndexOf("/mistral") is not -1 || modelName.IndexOf("/pixtral") is not -1)
{
if (modelName.IndexOf("/pixtral") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
// Meta Llama models via OpenRouter:
//
if (modelName.IndexOf("/llama") is not -1)
{
// Llama 4 with vision
if (modelName.IndexOf("/llama-4") is not -1 ||
modelName.IndexOf("/llama4") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Vision models
if (modelName.IndexOf("-vision") is not -1)
return [
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
// Llama 3.1+ with function calling
if (modelName.IndexOf("/llama-3.") is not -1 ||
modelName.IndexOf("/llama3.") is not -1)
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Default Llama
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
//
// Qwen models via OpenRouter:
//
if (modelName.IndexOf("/qwen") is not -1 || modelName.IndexOf("/qwq") is not -1)
{
if (modelName.IndexOf("/qwq") is not -1)
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING,
Capability.CHAT_COMPLETION_API,
];
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
//
// Default for unknown models:
// Assume basic text input/output with chat completion
//
return [
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
}
@@ -71,11 +71,36 @@ public static partial class ProviderExtensions
];
}
//
// Meta Muse models. They need their own block because their names do not
// contain "llama". Muse Glimmer always reasons: its chat template opens the
// thinking channel unconditionally, only the reasoning strength can be lowered.
//
if (modelName.IndexOf("muse-glimmer") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
//
// DeepSeek models:
//
if (modelName.IndexOf("deepseek") is not -1)
{
if ((modelName.IndexOf("deepseek-v4-flash") is not -1 ||
modelName.IndexOf("deepseek-v4-pro") is not -1) &&
modelName.IndexOf("-base") is -1)
return
[
Capability.TEXT_INPUT, Capability.TEXT_OUTPUT,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
if(modelName.IndexOf("deepseek-r1") is not -1 ||
modelName.IndexOf("deepseek r1") is not -1)
return [
@@ -101,6 +126,40 @@ public static partial class ProviderExtensions
Capability.ALWAYS_REASONING,
Capability.CHAT_COMPLETION_API,
];
// Check for the open-weight Qwen 3.8 checkpoint:
if(modelName.IndexOf("qwen3.8-2.4t-a95b") is not -1)
return
[
Capability.TEXT_INPUT, Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Check for the Qwen 3.8 Flash models. The open weights are published as
// Flash-Next, while Flash without the suffix is the production model. Both
// share the same capabilities, so one check covers them:
if(modelName.IndexOf("qwen3.8-flash") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Check for the multimodal Qwen 3.8 27B checkpoint:
if(modelName.IndexOf("qwen3.8-27b") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Check for Qwen 3.5:
if(modelName.IndexOf("qwen3.5") is not -1)
@@ -117,11 +176,10 @@ public static partial class ProviderExtensions
if(modelName.IndexOf("qwen3.6") is not -1)
return
[
Capability.TEXT_INPUT, Capability.VIDEO_INPUT,
Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
@@ -139,7 +197,55 @@ public static partial class ProviderExtensions
Capability.CHAT_COMPLETION_API,
];
}
//
// Moonshot AI / Kimi models:
//
if (modelName.IndexOf("kimi-k3") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
if (modelName.IndexOf("kimi-k2.7-code") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
//
// Tencent Hunyuan models. Hy3 answers directly by default: its reasoning_effort
// parameter defaults to no_think, low and high must be requested. We also match
// the short name because providers offer the model as tencent/hy3, so checking
// the start of the name is not enough.
//
if (modelName.IndexOf("hunyuan") is not -1 ||
modelName.IndexOf("hy3") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
//
// Ministral models. They need their own block because their names do not contain
// "mistral" as a substring, so the block below never sees them. Only Ministral 3 accepts
// images, the 2024 models are text only, which is why the release date decides here too:
//
if (modelName.IndexOf("ministral") is not -1)
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MINISTRAL_LATEST), MINISTRAL_VISION_SINCE, MISTRAL_REASONING_NEVER);
//
// Mistral models:
//
@@ -188,19 +294,6 @@ public static partial class ProviderExtensions
Capability.CHAT_COMPLETION_API,
];
if (modelName.IndexOf("mistral-small-4") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
if (modelName.IndexOf("mistral-small-3") is not -1 ||
modelName.IndexOf("mistral-small-4") is not -1)
return
@@ -282,6 +375,18 @@ public static partial class ProviderExtensions
Capability.CHAT_COMPLETION_API,
];
// Grok 4 models take text, images, and video natively. Reasoning is always
// on, only the reasoning effort can be configured:
if(modelName.IndexOf("grok-4") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
if(modelName.StartsWith("grok-3-mini"))
return
[
@@ -293,14 +398,25 @@ public static partial class ProviderExtensions
];
if(modelName.StartsWith("grok-3"))
return
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
// Any other Grok model. Without this, unknown Grok versions would fall
// through to the global default and would lose function calling:
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
@@ -330,11 +446,137 @@ public static partial class ProviderExtensions
];
}
//
// NVIDIA Nemotron models. They are built for agentic workloads and are text
// only. Reasoning has to be requested through enable_thinking, so it is
// optional. The check also covers the quantized checkpoints such as
// NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4.
//
if (modelName.IndexOf("nemotron") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
//
// Google Gemma models. Gemma is the open-weights family, while Gemini is not, which is why
// Gemma is handled here and Gemini in the Google implementation.
//
if (modelName.IndexOf("gemma") is not -1)
{
//
// Every checkpoint of the Gemma 4 generation is multimodal and understands video as
// well; there is no text-only variant. Audio input is limited to the E2B, E4B, and 12B
// checkpoints. The models can think, but only when the request asks them to: their chat
// template keeps the thinking channel closed by default.
//
if (modelName.IndexOf("gemma-4") is not -1 ||
modelName.IndexOf("gemma4") is not -1 ||
modelName.IndexOf("gemma 4") is not -1)
{
if (modelName.IndexOf("e2b") is not -1 ||
modelName.IndexOf("e4b") is not -1 ||
modelName.IndexOf("12b") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.AUDIO_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
// Gemma 3 accepts images from the 4B checkpoint upwards; the 1B one is text-only. This
// generation does not reason. The check for the small checkpoint looks for "-1b" rather
// than "1b", so that a name such as gemma-3-31b does not match it.
//
if (modelName.IndexOf("gemma-3") is not -1 ||
modelName.IndexOf("gemma3") is not -1 ||
modelName.IndexOf("gemma 3") is not -1)
{
if (modelName.IndexOf("-1b") is not -1)
return
[
Capability.TEXT_INPUT, Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
// The earlier generations take text only and were not built for tool usage:
//
return
[
Capability.TEXT_INPUT, Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
//
// Z AI / GLM models:
//
if (modelName.IndexOf("glm") is not -1)
{
//
// Both version checks below accept a hyphen as the version separator as well:
// Mistral serves these models as glm-5-2 and zai-glm-5-2, while everybody else
// writes the version with a dot.
//
// GLM 5.3 uses forced thinking: the reasoning effort can be lowered, but
// reasoning cannot be turned off. This check must stay in front of the
// vision check below, because quantized builds such as GLM-5.3-Flash-NVFP4
// contain a "v" and would be misread as a vision model:
if (modelName.IndexOf("glm-5.3") is not -1 ||
modelName.IndexOf("glm-5-3") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
if (modelName.IndexOf("glm-5.2") is not -1 ||
modelName.IndexOf("glm-5-2") is not -1)
return
[
Capability.TEXT_INPUT,
Capability.TEXT_OUTPUT,
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
if(modelName.IndexOf("v") is not -1)
return
[
@@ -90,6 +90,9 @@ public static partial class ProviderExtensions
GetQwenReasoningState(parameters)),
LLMProviders.OPEN_ROUTER or
LLMProviders.HETZNER or
LLMProviders.IONOS or
LLMProviders.LITE_LLM or
LLMProviders.X or
LLMProviders.DEEP_SEEK or
LLMProviders.GROQ or
@@ -1,4 +1,5 @@
using AIStudio.Provider;
using AIStudio.Provider.HuggingFace;
namespace AIStudio.Settings;
@@ -53,11 +54,27 @@ public static partial class ProviderExtensions
LLMProviders.DEEP_SEEK => GetModelCapabilitiesDeepSeek(model),
LLMProviders.ALIBABA_CLOUD => GetModelCapabilitiesAlibaba(model),
LLMProviders.PERPLEXITY => GetModelCapabilitiesPerplexity(model),
LLMProviders.OPEN_ROUTER => GetModelCapabilitiesOpenRouter(model),
LLMProviders.OPEN_ROUTER => GetModelCapabilitiesGateway(model),
LLMProviders.HETZNER or LLMProviders.IONOS => GetModelCapabilitiesOpenSource(model),
//
// LiteLLM is a gateway just like OpenRouter, and it names its models the same way:
// "vendor/model", e.g. "anthropic/claude-opus-5" or "azure/gpt-5.6". So we let the
// gateway detection handle it, which resolves the vendor prefix and asks the
// provider who really knows the model. Everything it cannot place is treated as
// an open source model, which is the right fallback for a freely named alias:
//
LLMProviders.LITE_LLM => GetModelCapabilitiesGateway(model),
LLMProviders.GROQ => GetModelCapabilitiesOpenSource(model),
LLMProviders.FIREWORKS => GetModelCapabilitiesOpenSource(model),
LLMProviders.HUGGINGFACE => GetModelCapabilitiesOpenSource(model),
LLMProviders.GROQ or LLMProviders.FIREWORKS => GetModelCapabilitiesOpenSource(model),
//
// Hugging Face names its models the way the hub does, "org/model", which is the same
// shape the other gateways use. So we let the gateway detection resolve the organization
// and ask the provider implementation which really knows the model. The routing suffix
// has to go first: it says which inference provider answers, not what the model is.
//
LLMProviders.HUGGINGFACE => GetModelCapabilitiesGateway(model.WithoutRoutingSuffix()),
LLMProviders.HELMHOLTZ => GetModelCapabilitiesOpenSource(model),
LLMProviders.GWDG => GetModelCapabilitiesOpenSource(model),
@@ -1,9 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
@@ -16,6 +16,8 @@ namespace AIStudio.Settings;
/// </summary>
public sealed class SettingsManager
{
public readonly record struct ToolMinimumProviderConfidenceResolution(ConfidenceLevel ConfidenceLevel, string Source);
private const string SETTINGS_FILENAME = "settings.json";
private const Version CURRENT_SETTINGS_VERSION = Version.V6;
@@ -23,7 +25,7 @@ public sealed class SettingsManager
private readonly record struct CurrentSettingsReadResult(Data? SettingsData, SettingsWriteBlockReason FailureReason);
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
internal static readonly JsonSerializerOptions JSON_OPTIONS = new()
{
WriteIndented = true,
Converters = { new TolerantEnumConverter() },
@@ -335,10 +337,35 @@ public sealed class SettingsManager
}
var settingsJson = JsonSerializer.Serialize(settingsData, JSON_OPTIONS);
var tempFile = Path.GetTempFileName();
await File.WriteAllTextAsync(tempFile, settingsJson);
File.Move(tempFile, settingsPath, true);
//
// We write the new settings next to the previous ones and replace them afterwards, so that
// no crash can leave a half-written settings file behind. The temporary file has to live in
// the configuration directory for that: replacing a file is a rename, and a rename across a
// file system boundary falls back to copying, which is exactly what we want to avoid. The
// temporary directory of the operating system is such another file system under Flatpak.
//
var tempFile = $"{settingsPath}.tmp-{Guid.NewGuid():N}";
try
{
await File.WriteAllTextAsync(tempFile, settingsJson);
File.Move(tempFile, settingsPath, true);
}
catch
{
try
{
if (File.Exists(tempFile))
File.Delete(tempFile);
}
catch (Exception cleanupException)
{
this.logger.LogWarning(cleanupException, $"Failed to delete the temporary settings file '{tempFile}'.");
}
throw;
}
this.logger.LogInformation($"Stored the settings to '{settingsPath}'.");
}
@@ -361,9 +388,16 @@ public sealed class SettingsManager
/// <summary>
/// Checks if the given plugin is enabled.
/// </summary>
/// <remarks>
/// Which plugins are enabled is the user's decision, with two exceptions. Configuration plugins
/// have no switch at all: they carry what an organization configured, so turning them off would
/// mean opting out of that configuration. And an organization may require one of the assistant
/// plugins it approved to stay enabled, which is decided live from its approvals rather than from
/// the user's list.
/// </remarks>
/// <param name="plugin">The plugin to check.</param>
/// <returns>True, when the plugin is enabled, false otherwise.</returns>
public bool IsPluginEnabled(IPluginMetadata plugin) => plugin.Type is PluginType.CONFIGURATION || this.ConfigurationData.EnabledPlugins.Contains(plugin.Id);
public bool IsPluginEnabled(IPluginMetadata plugin) => plugin.Type is PluginType.CONFIGURATION || this.ConfigurationData.EnabledPlugins.Contains(plugin.Id) || PluginFactory.IsAssistantActivationEnforced(plugin.Id);
/// <summary>
/// Returns the active language plugin.
@@ -434,7 +468,6 @@ public sealed class SettingsManager
return localeTag[..separatorIndex];
}
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false)
{
var minimumLevel = this.GetMinimumConfidenceLevel(component);
@@ -486,15 +519,27 @@ public sealed class SettingsManager
return this.ConfigurationData.Providers.FirstOrDefault(x => x.Id == this.ConfigurationData.App.PreselectedProvider && x.UsedLLMProvider.GetConfidence(this).Level >= minimumLevel) ?? Provider.NONE;
}
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
public Provider GetChatProviderForLoadedChat(string? chatProviderId = null)
{
var minimumLevel = this.GetMinimumConfidenceLevel(Tools.Components.CHAT);
bool IsSelectableProvider(Provider provider) =>
provider != Provider.NONE
&& provider.UsedLLMProvider != LLMProviders.NONE
&& provider.UsedLLMProvider.GetConfidence(this).Level >= minimumLevel;
var chatProvider = FindProviderById(chatProviderId);
if (chatProvider is not null)
return chatProvider;
var defaultChatProvider = this.ConfigurationData.Chat.PreselectOptions
? FindProviderById(this.ConfigurationData.Chat.PreselectedProvider)
: null;
if (defaultChatProvider is not null)
return defaultChatProvider;
var defaultAppProvider = FindProviderById(this.ConfigurationData.App.PreselectedProvider);
if (defaultAppProvider is not null)
return defaultAppProvider;
var selectableProviders = this.ConfigurationData.Providers.Where(IsSelectableProvider).ToList();
return selectableProviders.Count == 1 ? selectableProviders[0] : Provider.NONE;
Provider? FindProviderById(string? providerId)
{
@@ -505,22 +550,185 @@ public sealed class SettingsManager
return provider is not null && IsSelectableProvider(provider) ? provider : null;
}
var chatProvider = FindProviderById(chatProviderId);
if (chatProvider is not null)
return chatProvider;
bool IsSelectableProvider(Provider provider) =>
provider != Provider.NONE
&& provider.UsedLLMProvider != LLMProviders.NONE
&& provider.UsedLLMProvider.GetConfidence(this).Level >= minimumLevel;
}
var defaultChatProvider = this.ConfigurationData.Chat.PreselectOptions
? FindProviderById(this.ConfigurationData.Chat.PreselectedProvider)
: null;
if (defaultChatProvider is not null)
return defaultChatProvider;
/// <summary>
/// Returns all configured providers without applying any confidence filtering.
/// </summary>
/// <remarks>
/// <para>
/// This method applies neither the global minimum confidence level (see
/// <see cref="Data.Confidence"/> with <c>EnforceGlobalMinimumConfidence</c>) nor any
/// component-specific minimum. Even when the user enforces a global minimum of, say,
/// <see cref="ConfidenceLevel.HIGH"/>, this method still returns every configured provider.
/// That is intentional: this method serves the provider management UI, duplicate-name checks,
/// and the raw select data of provider dropdowns. The dropdowns are filtered afterward by
/// ConfigurationProviderSelection, which calls IsProviderConfident.
/// </para>
/// <para>
/// Whenever a provider is about to be used for an LLM request, do not use this method. Use
/// GetConfidentProviders, GetPreselectedProvider, or GetChatProviderForLoadedChat instead,
/// since they honor the confidence levels.
/// </para>
/// <para>
/// The returned list is a sorted copy of the provider list, ordered by the used LLM provider and
/// then by the instance name. This way, all providers of the same LLM provider stay together, and
/// newly added providers appear at their alphabetical position instead of at the end. Callers must
/// not mutate the returned list: adding, editing, or removing providers stays inside the settings UI.
/// </para>
/// </remarks>
/// <returns>All configured providers, unfiltered.</returns>
public IReadOnlyList<Provider> GetAllProviders() => this.ConfigurationData.Providers
.OrderBy(x => x.UsedLLMProvider.ToName(), StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.InstanceName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Num)
.ToList();
var defaultAppProvider = FindProviderById(this.ConfigurationData.App.PreselectedProvider);
if (defaultAppProvider is not null)
return defaultAppProvider;
/// <summary>
/// Returns the provider with the given id, without applying any confidence filtering.
/// </summary>
/// <remarks>
/// This method resolves a stored provider reference by its id. It applies neither the global
/// minimum confidence level nor any component-specific minimum, so it returns the requested
/// provider even when the user enforces a higher global minimum. Callers that intend to use the
/// returned provider for an LLM request must check it themselves through
/// IsProviderConfident or fall back to GetPreselectedProvider.
/// </remarks>
/// <param name="providerId">The id of the provider to look up.</param>
/// <returns>The provider, or <see cref="Provider.NONE"/> when no provider with that id exists.</returns>
public Provider GetProviderById(string? providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
return Provider.NONE;
var selectableProviders = this.ConfigurationData.Providers.Where(IsSelectableProvider).ToList();
return selectableProviders.Count == 1 ? selectableProviders[0] : Provider.NONE;
if (string.Equals(providerId, Provider.NONE.Id, StringComparison.OrdinalIgnoreCase))
return Provider.NONE;
return this.ConfigurationData.Providers.FirstOrDefault(x => x.Id.Equals(providerId, StringComparison.OrdinalIgnoreCase)) ?? Provider.NONE;
}
/// <summary>
/// Determines the minimum confidence level a provider must have for the given component.
/// </summary>
/// <param name="component">The component for which the providers get filtered.</param>
/// <param name="explicitMinimum">An explicit minimum level, which is applied when it is higher than the component's minimum.</param>
/// <returns>The effective minimum confidence level.</returns>
public ConfidenceLevel GetEffectiveMinimumConfidenceLevel(Tools.Components component, ConfidenceLevel explicitMinimum = ConfidenceLevel.UNKNOWN)
{
var minimumLevel = this.GetMinimumConfidenceLevel(component);
if (explicitMinimum is not ConfidenceLevel.UNKNOWN && explicitMinimum > minimumLevel)
return explicitMinimum;
return minimumLevel;
}
/// <summary>
/// Checks whether the given provider satisfies the minimum confidence level of the given component.
/// </summary>
/// <param name="provider">The provider to check.</param>
/// <param name="component">The component for which the provider gets checked.</param>
/// <param name="explicitMinimum">An explicit minimum level, which is applied when it is higher than the component's minimum.</param>
/// <returns>True, when the provider may be used by the component, false otherwise.</returns>
public bool IsProviderConfident(Provider provider, Tools.Components component, ConfidenceLevel explicitMinimum = ConfidenceLevel.UNKNOWN)
{
if (provider.UsedLLMProvider is LLMProviders.NONE)
return false;
return provider.UsedLLMProvider.GetConfidence(this).Level >= this.GetEffectiveMinimumConfidenceLevel(component, explicitMinimum);
}
/// <summary>
/// Returns all providers that satisfy the minimum confidence level of the given component.
/// </summary>
/// <param name="component">The component for which the providers get filtered.</param>
/// <param name="explicitMinimum">An explicit minimum level, which is applied when it is higher than the component's minimum.</param>
/// <returns>All providers the component may use, in the same order as GetAllProviders.</returns>
public IEnumerable<Provider> GetConfidentProviders(Tools.Components component, ConfidenceLevel explicitMinimum = ConfidenceLevel.UNKNOWN)
{
var minimumLevel = this.GetEffectiveMinimumConfidenceLevel(component, explicitMinimum);
foreach (var provider in this.GetAllProviders())
if (provider.UsedLLMProvider is not LLMProviders.NONE && provider.UsedLLMProvider.GetConfidence(this).Level >= minimumLevel)
yield return provider;
}
/// <summary>
/// Returns all configured embedding providers.
/// </summary>
/// <remarks>
/// The returned list is a sorted copy of the embedding provider list, ordered by the used LLM
/// provider and then by the name. Callers must not mutate the returned list: adding, editing, or
/// removing embedding providers stays inside the settings UI.
/// </remarks>
/// <returns>All configured embedding providers.</returns>
public IReadOnlyList<EmbeddingProvider> GetAllEmbeddingProviders() => this.ConfigurationData.EmbeddingProviders
.OrderBy(x => x.UsedLLMProvider.ToName(), StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Num)
.ToList();
/// <summary>
/// Returns the embedding provider with the given id, without applying any confidence filtering.
/// </summary>
/// <remarks>
/// This method resolves a stored embedding provider reference by its id. It applies neither the
/// global minimum confidence level nor any component-specific minimum, so it returns the
/// requested embedding provider even when the user enforces a higher global minimum. Callers
/// that intend to send data to the returned embedding provider must check it themselves, for
/// example through IsTrustedForDataSourceSecurityChecks.
/// </remarks>
/// <param name="embeddingProviderId">The id of the embedding provider to look up.</param>
/// <returns>The embedding provider, or EmbeddingProvider.NONE when no embedding provider with that id exists.</returns>
public EmbeddingProvider GetEmbeddingProviderById(string? embeddingProviderId)
{
if (string.IsNullOrWhiteSpace(embeddingProviderId))
return EmbeddingProvider.NONE;
if (string.Equals(embeddingProviderId, EmbeddingProvider.NONE.Id, StringComparison.OrdinalIgnoreCase))
return EmbeddingProvider.NONE;
return this.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id.Equals(embeddingProviderId, StringComparison.OrdinalIgnoreCase)) ?? EmbeddingProvider.NONE;
}
/// <summary>
/// Returns all configured transcription providers.
/// </summary>
/// <remarks>
/// The returned list is a sorted copy of the transcription provider list, ordered by the used LLM
/// provider and then by the name. Callers must not mutate the returned list: adding, editing, or
/// removing transcription providers stays inside the settings UI.
/// </remarks>
/// <returns>All configured transcription providers.</returns>
public IReadOnlyList<TranscriptionProvider> GetAllTranscriptionProviders() => this.ConfigurationData.TranscriptionProviders
.OrderBy(x => x.UsedLLMProvider.ToName(), StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Num)
.ToList();
/// <summary>
/// Returns the transcription provider with the given id, without applying any confidence filtering.
/// </summary>
/// <remarks>
/// This method resolves a stored transcription provider reference by its id. It applies neither
/// the global minimum confidence level nor any component-specific minimum, so it returns the
/// requested transcription provider even when the user enforces a higher global minimum. Callers
/// that intend to send audio to the returned transcription provider must check its confidence
/// level themselves, the way GetFilteredTranscriptionProviders does for the app settings.
/// </remarks>
/// <param name="transcriptionProviderId">The id of the transcription provider to look up.</param>
/// <returns>The transcription provider, or TranscriptionProvider.NONE when no transcription provider with that id exists.</returns>
public TranscriptionProvider GetTranscriptionProviderById(string? transcriptionProviderId)
{
if (string.IsNullOrWhiteSpace(transcriptionProviderId))
return TranscriptionProvider.NONE;
if (string.Equals(transcriptionProviderId, TranscriptionProvider.NONE.Id, StringComparison.OrdinalIgnoreCase))
return TranscriptionProvider.NONE;
return this.ConfigurationData.TranscriptionProviders.FirstOrDefault(x => x.Id.Equals(transcriptionProviderId, StringComparison.OrdinalIgnoreCase)) ?? TranscriptionProvider.NONE;
}
public Profile GetPreselectedProfile(Tools.Components component)
@@ -579,6 +787,112 @@ public sealed class SettingsManager
return this.ConfigurationData.ChatTemplates.FirstOrDefault(x => x.Id.Equals(chatTemplateId, StringComparison.OrdinalIgnoreCase)) ?? ChatTemplate.NO_CHAT_TEMPLATE;
}
public HashSet<string> GetDefaultToolIds(AIStudio.Tools.Components component)
{
var key = component.ToString();
if (this.ConfigurationData.Tools.DefaultToolIdsByComponent.TryGetValue(key, out var toolIds))
return ToolSelectionRules.NormalizeSelection(toolIds);
return [];
}
public bool AreToolsEnabled() => this.ConfigurationData.Tools.EnableTools;
public bool IsToolActive(string toolId) =>
this.AreToolsEnabled() &&
!this.ConfigurationData.Tools.DisabledToolIds.Contains(toolId);
/// <remarks>
/// The document analysis is deliberately absent: there its policy names the tools, so the user
/// has nothing to select.
/// </remarks>
public bool IsToolSelectionVisible(AIStudio.Tools.Components component) => component switch
{
AIStudio.Tools.Components.CHAT or
AIStudio.Tools.Components.CODING_ASSISTANT or
AIStudio.Tools.Components.SLIDE_BUILDER_ASSISTANT => true,
_ => this.ConfigurationData.Tools.VisibleToolSelectionComponents.Contains(component.ToString()),
};
public void SetToolSelectionVisibility(AIStudio.Tools.Components component, bool isVisible)
{
if (component is
AIStudio.Tools.Components.CHAT or
AIStudio.Tools.Components.CODING_ASSISTANT or
AIStudio.Tools.Components.SLIDE_BUILDER_ASSISTANT)
return;
var key = component.ToString();
if (isVisible)
this.ConfigurationData.Tools.VisibleToolSelectionComponents.Add(key);
else
this.ConfigurationData.Tools.VisibleToolSelectionComponents.Remove(key);
}
/// <summary>
/// Resolves which provider confidence a tool needs, and where that value came from.
/// </summary>
/// <remarks>
/// The default is passed in rather than looked up here. It belongs to the tool definition,
/// and the definitions live in the tool registry — which already depends on this class, so
/// asking it back would be a circle. Every caller has the definition at hand anyway.
/// </remarks>
/// <param name="toolId">The tool to resolve the confidence for.</param>
/// <param name="defaultLevel">The tool's own minimum, used when nothing overrides it.</param>
public ToolMinimumProviderConfidenceResolution GetMinimumProviderConfidenceResolutionForTool(string toolId, ConfidenceLevel defaultLevel)
{
if (ManagedConfiguration.TryGet(x => x.Tools, x => x.MinimumProviderConfidenceByToolId, out var configMeta) && configMeta.IsLocked)
{
var managedValues = configMeta.GetValue();
if (managedValues.TryGetValue(toolId, out var configuredManagedLevel) &&
Enum.TryParse<ConfidenceLevel>(configuredManagedLevel, true, out var managedConfidenceLevel) &&
Enum.IsDefined(managedConfidenceLevel) &&
managedConfidenceLevel is not ConfidenceLevel.UNKNOWN)
{
return new(managedConfidenceLevel, "managed config");
}
if (managedValues.ContainsKey(toolId))
{
this.logger.LogError(
"Managed minimum provider confidence '{ConfiguredLevel}' for tool '{ToolId}' is invalid. Requiring HIGH as a safe fallback.",
configuredManagedLevel,
toolId);
return new(ConfidenceLevel.HIGH, "invalid managed config; safe fallback");
}
}
if (this.ConfigurationData.Tools.MinimumProviderConfidenceByToolId.TryGetValue(toolId, out var configuredLevel) &&
Enum.TryParse<ConfidenceLevel>(configuredLevel, true, out var confidenceLevel) &&
Enum.IsDefined(confidenceLevel) &&
confidenceLevel is not ConfidenceLevel.UNKNOWN)
{
return new(confidenceLevel, "stored override");
}
return new(defaultLevel, "default fallback");
}
public ConfidenceLevel GetMinimumProviderConfidenceForTool(string toolId, ConfidenceLevel defaultLevel) => this.GetMinimumProviderConfidenceResolutionForTool(toolId, defaultLevel).ConfidenceLevel;
/// <summary>
/// Stores which provider confidence a tool needs.
/// </summary>
/// <param name="toolId">The tool to store the confidence for.</param>
/// <param name="confidenceLevel">The level the user chose.</param>
/// <param name="defaultLevel">The tool's own minimum. Choosing it again removes the override.</param>
public void SetMinimumProviderConfidenceForTool(string toolId, ConfidenceLevel confidenceLevel, ConfidenceLevel defaultLevel)
{
if (confidenceLevel == defaultLevel)
{
this.ConfigurationData.Tools.MinimumProviderConfidenceByToolId.Remove(toolId);
return;
}
this.ConfigurationData.Tools.MinimumProviderConfidenceByToolId[toolId] = confidenceLevel.ToString();
}
public ConfidenceLevel GetConfiguredConfidenceLevel(LLMProviders llmProvider)
{
if(llmProvider is LLMProviders.NONE)
@@ -599,8 +913,9 @@ public sealed class SettingsManager
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
LLMProviders.DEEP_SEEK => ConfidenceLevel.LOW,
LLMProviders.ALIBABA_CLOUD => ConfidenceLevel.LOW,
_ => ConfidenceLevel.MEDIUM,
_ => ConfidenceLevel.MEDIUM,
};
case ConfidenceSchemes.TRUST_USA:
@@ -610,7 +925,10 @@ public sealed class SettingsManager
LLMProviders.MISTRAL => ConfidenceLevel.LOW,
LLMProviders.HELMHOLTZ => ConfidenceLevel.LOW,
LLMProviders.GWDG => ConfidenceLevel.LOW,
LLMProviders.HETZNER => ConfidenceLevel.LOW,
LLMProviders.IONOS => ConfidenceLevel.LOW,
LLMProviders.DEEP_SEEK => ConfidenceLevel.LOW,
LLMProviders.ALIBABA_CLOUD => ConfidenceLevel.LOW,
_ => ConfidenceLevel.MEDIUM,
};
@@ -622,6 +940,8 @@ public sealed class SettingsManager
LLMProviders.MISTRAL => ConfidenceLevel.MEDIUM,
LLMProviders.HELMHOLTZ => ConfidenceLevel.MEDIUM,
LLMProviders.GWDG => ConfidenceLevel.MEDIUM,
LLMProviders.HETZNER => ConfidenceLevel.MEDIUM,
LLMProviders.IONOS => ConfidenceLevel.MEDIUM,
_ => ConfidenceLevel.LOW,
};
@@ -631,6 +951,7 @@ public sealed class SettingsManager
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
LLMProviders.DEEP_SEEK => ConfidenceLevel.MEDIUM,
LLMProviders.ALIBABA_CLOUD => ConfidenceLevel.MEDIUM,
_ => ConfidenceLevel.LOW,
};
@@ -667,4 +988,4 @@ public sealed class SettingsManager
// Return the full name of the property, including the class name:
return $"{typeof(TIn).Name}.{memberExpr.Member.Name}";
}
}
}
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Provider.HuggingFace;
using AIStudio.Tools.PluginSystem;
using SharedTools;
@@ -20,7 +21,10 @@ public sealed record TranscriptionProvider(
bool IsEnterpriseConfiguration = false,
Guid EnterpriseConfigurationPluginId = default,
string Hostname = "http://localhost:1234",
Host Host = Host.NONE) : ConfigurationBaseObject, ISecretId
Host Host = Host.NONE,
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "",
HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger<TranscriptionProvider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<TranscriptionProvider>();
@@ -52,7 +56,7 @@ public sealed record TranscriptionProvider(
#endregion
public static bool TryParseTranscriptionProviderTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject provider)
public static bool TryParseTranscriptionProviderTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject provider)
{
provider = NONE;
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
@@ -97,6 +101,29 @@ public sealed record TranscriptionProvider(
return false;
}
var allowUserProvidedApiKey = false;
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var hfInferenceProvider = HFInferenceProvider.NONE;
if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead<string>(out var hfInferenceProviderText))
{
if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider))
{
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})");
hfInferenceProvider = HFInferenceProvider.NONE;
}
}
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
if (!iconPathValue.TryRead<string>(out var iconPath))
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid icon path. Falling back to the built-in provider icon. (Plugin ID: {configPluginId})");
else if (!PluginIconFile.TryLoadDataUrl(iconPath, pluginPath, out customIconDataUrl, out var iconIssue))
LOGGER.LogWarning($"The configured transcription provider {idx} contains an invalid icon path. Falling back to the built-in provider icon. Issue: {iconIssue} (Plugin ID: {configPluginId})");
}
provider = new TranscriptionProvider
{
Num = 0, // will be set later by the PluginConfigurationObject
@@ -109,10 +136,20 @@ public sealed record TranscriptionProvider(
EnterpriseConfigurationPluginId = configPluginId,
Hostname = hostname,
Host = host,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
HFInferenceProvider = hfInferenceProvider,
};
// Handle encrypted API key if present:
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
// Handle an encrypted API key if present. When the user manages their own key for this
// transcription provider, we must never enqueue an embedded key: doing so would overwrite
// the user's key in the OS keyring on every configuration reload.
if (allowUserProvidedApiKey)
{
if (table.TryGetValue("APIKey", out var ignoredApiKeyValue) && ignoredApiKeyValue.TryRead<string>(out var ignoredApiKeyText) && !string.IsNullOrWhiteSpace(ignoredApiKeyText))
LOGGER.LogWarning($"The configured transcription provider {idx} sets both AllowUserProvidedAPIKey and an embedded APIKey. Ignoring the embedded key: the user manages their own key for this provider. (Plugin ID: {configPluginId})");
}
else if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
{
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
@@ -168,6 +205,14 @@ public sealed record TranscriptionProvider(
/// <returns>A Lua configuration section string.</returns>
public string ExportAsConfigurationSection(string? encryptedApiKey = null)
{
var hfInferenceProviderLine = string.Empty;
if (this.HFInferenceProvider is not HFInferenceProvider.NONE)
{
hfInferenceProviderLine = $"""
["HFInferenceProvider"] = "{this.HFInferenceProvider}",
""";
}
var apiKeyLine = string.Empty;
if (!string.IsNullOrWhiteSpace(encryptedApiKey))
{
@@ -184,6 +229,7 @@ public sealed record TranscriptionProvider(
["Host"] = "{{this.Host}}",
["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}",
{{hfInferenceProviderLine}}
{{apiKeyLine}}
["Model"] = {
["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}",