Files
AI-Studio/app/MindWork AI Studio/Settings/SettingsManager.cs
T

1310 lines
64 KiB
C#

using System.Diagnostics;
using System.Linq.Expressions;
using System.Text;
using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
// ReSharper disable NotAccessedPositionalProperty.Local
namespace AIStudio.Settings;
/// <summary>
/// The settings manager.
/// </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;
private readonly record struct SettingsVersionReadResult(Version Version, SettingsWriteBlockReason FailureReason);
private readonly record struct CurrentSettingsReadResult(Data? SettingsData, SettingsWriteBlockReason FailureReason);
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
{
WriteIndented = true,
Converters = { new TolerantEnumConverter() },
TypeInfoResolver = SettingsJsonContext.Default,
}.AsReadOnly();
/// <summary>
/// From this duration on, storing the settings is reported as slow.
/// </summary>
/// <remarks>
/// The settings file is a few hundred kilobytes at most, so storing it usually takes some
/// milliseconds. Far beyond that, something is blocking: on one machine, every store took 18
/// seconds, and each one held up the start of AI Studio.
/// </remarks>
private static readonly TimeSpan SLOW_STORE_THRESHOLD = TimeSpan.FromSeconds(1);
private readonly ILogger<SettingsManager> logger;
private readonly RustService rustService;
/// <summary>
/// Lets only one operation at a time touch the settings files.
/// </summary>
/// <remarks>
/// Reading takes this as well as writing does, for two reasons. A read migrates and backs up
/// what it found, so it writes the very files a store writes. And it re-evaluates whether
/// writes are blocked at all, starting out by clearing that block: a store slipping through
/// that moment would overwrite the settings the block exists to protect.<br/><br/>
/// What this does not do is guard the settings themselves. It guards the files: what one store
/// writes, the next one no longer has to fear. The configuration data behind them stays open to
/// everybody, and a store serializes it while the rest of the app goes on editing it -- a list
/// growing mid-serialization still throws. Whoever wants that answered needs one of their own;
/// this lock is not it.
/// </remarks>
private readonly SemaphoreSlim settingsFileSemaphore = new(1, 1);
/// <summary>
/// The settings manager.
/// </summary>
public SettingsManager(ILogger<SettingsManager> logger, RustService rustService)
{
this.logger = logger;
this.rustService = rustService;
this.logger.LogInformation("Settings manager created.");
}
/// <summary>
/// The directory where the configuration files are stored.
/// </summary>
public static string? ConfigDirectory { get; set; }
/// <summary>
/// The directory where the data files are stored.
/// </summary>
public static string? DataDirectory { get; set; }
/// <summary>
/// Whether the app is in dark mode.
/// </summary>
public bool IsDarkMode { get; set; }
/// <summary>
/// Ensures that the startup start-page redirect is evaluated at most once per app session.
/// </summary>
public bool StartupStartPageRedirectHandled { get; set; }
/// <summary>
/// Indicates that the initial settings load attempt has completed.
/// </summary>
public bool HasCompletedInitialSettingsLoad { get; private set; }
/// <summary>
/// Indicates why settings writes are blocked for the current session.
/// </summary>
public SettingsWriteBlockReason SettingsWriteBlockReason { get; private set; } = SettingsWriteBlockReason.NONE;
/// <summary>
/// Indicates that settings writes are blocked for the current session.
/// </summary>
public bool SettingsWriteBlocked => this.SettingsWriteBlockReason is not SettingsWriteBlockReason.NONE;
/// <summary>
/// The configuration data.
/// </summary>
public Data ConfigurationData { get; private set; } = new();
private bool IsSetUp => !string.IsNullOrWhiteSpace(ConfigDirectory) && !string.IsNullOrWhiteSpace(DataDirectory);
/// <summary>
/// Loads the settings from the file system.
/// </summary>
public async Task LoadSettings()
{
var settingsSnapshot = await this.TryReadSettingsSnapshot();
if (settingsSnapshot is not null)
this.ConfigurationData = settingsSnapshot;
this.HasCompletedInitialSettingsLoad = true;
}
/// <summary>
/// Reads the settings from disk without mutating the current in-memory state.
/// </summary>
/// <returns>A (migrated) settings snapshot, or null if it could not be read.</returns>
public async Task<Data?> TryReadSettingsSnapshot()
{
await this.settingsFileSemaphore.WaitAsync();
try
{
return await this.ReadSettingsSnapshot();
}
finally
{
this.settingsFileSemaphore.Release();
}
}
private async Task<Data?> ReadSettingsSnapshot()
{
this.SettingsWriteBlockReason = SettingsWriteBlockReason.NONE;
if(!this.IsSetUp)
{
this.logger.LogWarning("Cannot load settings, because the configuration is not set up yet.");
return null;
}
var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME);
if(!File.Exists(settingsPath))
{
this.logger.LogWarning("Cannot load settings, because the settings file does not exist.");
return null;
}
var settingsVersion = await this.TryReadSettingsVersion(settingsPath);
if(settingsVersion.FailureReason is not SettingsWriteBlockReason.NONE)
{
this.BlockSettingsWrites(settingsVersion.FailureReason, "The settings file version could not be identified. Settings writes are blocked to avoid overwriting newer or unreadable settings.");
return await this.TryReadCurrentVersionBackupSnapshotForBlockedSettings();
}
if(settingsVersion.Version > CURRENT_SETTINGS_VERSION)
{
this.BlockSettingsWrites(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP, $"The settings file uses the newer version '{settingsVersion.Version}'. Settings writes are blocked to avoid overwriting newer settings.");
return await this.TryReadCurrentVersionBackupSnapshotForBlockedSettings();
}
Data? settingsData;
if(settingsVersion.Version < CURRENT_SETTINGS_VERSION)
{
settingsData = await this.TryReadCurrentVersionBackupSnapshot();
if(settingsData is not null)
{
this.PrepareLoadedSettings(settingsData);
await this.StoreSettingsSnapshot(settingsData, settingsPath);
await this.StoreCurrentVersionBackup(settingsData);
this.logger.LogInformation($"Restored settings from the '{GetBackupSettingsFilename(CURRENT_SETTINGS_VERSION)}' backup file.");
return settingsData;
}
this.logger.LogInformation("No valid current-version settings backup was found. Migrating the settings file.");
settingsData = SettingsMigrations.Migrate(this.logger, settingsVersion.Version, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
// Compatibility shim, remove after 2027-10-31, see KeepTheLegacyEncryptionExportFormat.
// Settings of an older version always come from before the export format existed:
settingsData.App.EnterpriseEncryptionExportFormat = EnterpriseEncryptionFormat.V1;
this.PrepareLoadedSettings(settingsData);
await this.StoreSettingsSnapshot(settingsData, settingsPath);
await this.StoreCurrentVersionBackup(settingsData);
return settingsData;
}
var currentSettings = await this.TryDeserializeCurrentSettings(settingsPath, "settings file");
if(currentSettings.FailureReason is not SettingsWriteBlockReason.NONE)
{
this.BlockSettingsWrites(currentSettings.FailureReason, "The current settings file could not be safely loaded. Settings writes are blocked to avoid overwriting recoverable settings.");
return await this.TryReadCurrentVersionBackupSnapshotForBlockedSettings();
}
settingsData = currentSettings.SettingsData!;
this.PrepareLoadedSettings(settingsData);
await this.StoreCurrentVersionBackup(settingsData);
return settingsData;
}
private async Task<SettingsVersionReadResult> TryReadSettingsVersion(string settingsPath)
{
try
{
await using var settingsStream = File.OpenRead(settingsPath);
using var settingsDocument = await JsonDocument.ParseAsync(settingsStream);
if(!settingsDocument.RootElement.TryGetProperty("Version", out var versionElement))
{
this.logger.LogError($"Failed to read the version of the settings file '{settingsPath}'.");
return new(Version.UNKNOWN, SettingsWriteBlockReason.VERSION_MISSING);
}
if(versionElement.ValueKind is JsonValueKind.String && versionElement.GetString() is { } versionText)
{
if(Enum.TryParse(versionText, out Version stringVersion) && Enum.IsDefined(stringVersion) && stringVersion is not Version.UNKNOWN)
return new(stringVersion, SettingsWriteBlockReason.NONE);
if(versionText.StartsWith('V') && int.TryParse(versionText[1..], out var futureVersion) && futureVersion > (int)CURRENT_SETTINGS_VERSION)
return new((Version)futureVersion, SettingsWriteBlockReason.NONE);
if(int.TryParse(versionText, out var numericStringVersion) && numericStringVersion > (int)CURRENT_SETTINGS_VERSION)
return new((Version)numericStringVersion, SettingsWriteBlockReason.NONE);
}
if(versionElement.ValueKind is JsonValueKind.Number && versionElement.TryGetInt32(out var numericVersion) && numericVersion > (int)Version.UNKNOWN && (Enum.IsDefined(typeof(Version), numericVersion) || numericVersion > (int)CURRENT_SETTINGS_VERSION))
return new((Version)numericVersion, SettingsWriteBlockReason.NONE);
}
catch(Exception e)
{
this.logger.LogError(e, $"Failed to read the version of the settings file '{settingsPath}'.");
return new(Version.UNKNOWN, SettingsWriteBlockReason.FILE_UNREADABLE);
}
return new(Version.UNKNOWN, SettingsWriteBlockReason.VERSION_UNKNOWN);
}
private async Task<Data?> TryReadCurrentVersionBackupSnapshot()
{
var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION);
if(!File.Exists(backupSettingsPath))
{
this.logger.LogInformation($"The settings backup file '{backupSettingsPath}' does not exist.");
return null;
}
var backupVersion = await this.TryReadSettingsVersion(backupSettingsPath);
if(backupVersion.FailureReason is not SettingsWriteBlockReason.NONE)
{
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' could not be used because its version could not be identified. Reason: '{backupVersion.FailureReason}'.");
return null;
}
if(backupVersion.Version != CURRENT_SETTINGS_VERSION)
{
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' uses version '{backupVersion.Version}' instead of '{CURRENT_SETTINGS_VERSION}'.");
return null;
}
var backupSettings = await this.TryDeserializeCurrentSettings(backupSettingsPath, "settings backup file");
if(backupSettings.FailureReason is not SettingsWriteBlockReason.NONE)
{
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' could not be used. Reason: '{backupSettings.FailureReason}'.");
return null;
}
return backupSettings.SettingsData;
}
private async Task<Data?> TryReadCurrentVersionBackupSnapshotForBlockedSettings()
{
var settingsData = await this.TryReadCurrentVersionBackupSnapshot();
if(settingsData is null)
{
this.logger.LogWarning($"No valid current-version settings backup was found while settings writes are blocked. Reason: '{this.SettingsWriteBlockReason}'.");
return null;
}
this.PrepareLoadedSettings(settingsData);
this.logger.LogWarning($"Loaded settings from the '{GetBackupSettingsFilename(CURRENT_SETTINGS_VERSION)}' backup file while settings writes remain blocked. Reason: '{this.SettingsWriteBlockReason}'.");
return settingsData;
}
private async Task<CurrentSettingsReadResult> TryDeserializeCurrentSettings(string settingsPath, string sourceDescription)
{
try
{
var settingsJson = await File.ReadAllTextAsync(settingsPath);
var settingsData = JsonSerializer.Deserialize<Data>(settingsJson, JSON_OPTIONS);
if(settingsData is null)
{
this.logger.LogError($"Failed to parse the {sourceDescription} '{settingsPath}'.");
return new(null, SettingsWriteBlockReason.CURRENT_VERSION_INVALID);
}
if(settingsData.Version != CURRENT_SETTINGS_VERSION)
{
this.logger.LogError($"The {sourceDescription} '{settingsPath}' uses version '{settingsData.Version}' instead of '{CURRENT_SETTINGS_VERSION}'.");
return new(null, SettingsWriteBlockReason.CURRENT_VERSION_INVALID);
}
KeepTheLegacyEncryptionExportFormat(settingsData, settingsJson);
return new(settingsData, SettingsWriteBlockReason.NONE);
}
catch(Exception e)
{
this.logger.LogError(e, $"Failed to parse the {sourceDescription} '{settingsPath}'.");
return new(null, SettingsWriteBlockReason.FILE_UNREADABLE);
}
}
/// <summary>
/// Lets an installation which existed before v26.10.1 keep exporting encrypted secrets in the
/// format v1, which every version of AI Studio reads.
/// </summary>
/// <remarks>
/// Compatibility shim, remove after 2027-10-31, see
/// documentation/compatibility-shims/2026-10-enterprise-encryption-export-format.md.<br/><br/>
/// New installations export in the authenticated format v2. An existing installation, however,
/// may belong to an administrator whose organization still runs an older version, which reads
/// v1 only. Settings written before v26.10.1 lack the export format, and nothing else tells
/// such an installation apart. Once the settings are stored, they hold the format, and this
/// changes nothing anymore.
/// </remarks>
private static void KeepTheLegacyEncryptionExportFormat(Data settingsData, string settingsJson)
{
using var settingsDocument = JsonDocument.Parse(settingsJson);
if(settingsDocument.RootElement.TryGetProperty(nameof(Data.App), out var appElement) && appElement.ValueKind is JsonValueKind.Object && appElement.TryGetProperty(nameof(DataApp.EnterpriseEncryptionExportFormat), out _))
return;
settingsData.App.EnterpriseEncryptionExportFormat = EnterpriseEncryptionFormat.V1;
}
private void BlockSettingsWrites(SettingsWriteBlockReason reason, string message)
{
this.SettingsWriteBlockReason = reason;
this.logger.LogError($"{message} Reason: '{reason}'.");
}
private void PrepareLoadedSettings(Data settingsData)
{
//
// We filter the enabled preview features based on the preview visibility.
// This is necessary when the app starts up: some preview features may have
// been disabled or released from the last time the app was started.
//
settingsData.App.EnabledPreviewFeatures = settingsData.App.PreviewVisibility.FilterPreviewFeatures(settingsData.App.EnabledPreviewFeatures);
}
/// <summary>
/// Stores the settings to the file system.
/// </summary>
public async Task StoreSettings()
{
await this.settingsFileSemaphore.WaitAsync();
try
{
if(!this.IsSetUp)
{
this.logger.LogWarning("Cannot store settings, because the configuration is not set up yet.");
return;
}
if(this.SettingsWriteBlocked)
{
this.logger.LogWarning($"Cannot store settings, because settings writes are blocked. Reason: '{this.SettingsWriteBlockReason}'.");
return;
}
//
// Serializing and writing are measured on their own, because a slow store has two
// different causes: a property which asks the file system or the network while it is
// serialized, or a file system which is slow to write, e.g., because of a virus scanner
// or a synchronized profile folder. Only the two durations tell them apart:
//
var serializingStartedAt = Stopwatch.GetTimestamp();
var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS);
var serializingDuration = Stopwatch.GetElapsedTime(serializingStartedAt);
var writingStartedAt = Stopwatch.GetTimestamp();
var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME);
await this.StoreSerializedSettings(settingsJson, settingsPath);
await this.StoreSerializedVersionBackup(this.ConfigurationData.Version, settingsJson);
var writingDuration = Stopwatch.GetElapsedTime(writingStartedAt);
if (serializingDuration + writingDuration >= SLOW_STORE_THRESHOLD)
this.logger.LogWarning($"Storing the settings took {(serializingDuration + writingDuration).TotalMilliseconds:F0} ms, which is unusually long: serializing took {serializingDuration.TotalMilliseconds:F0} ms, writing took {writingDuration.TotalMilliseconds:F0} ms. The settings are {Encoding.UTF8.GetByteCount(settingsJson) / 1024} KB. A slow serialization points to a setting which reaches out to the file system or the network while it is serialized; slow writing points to the file system itself, e.g., a virus scanner or a synchronized profile folder.");
}
finally
{
this.settingsFileSemaphore.Release();
}
}
private static string GetBackupSettingsFilename(Version version) => $"settings.{version.ToString().ToLowerInvariant()}.json";
private static string GetBackupSettingsPath(Version version) => Path.Combine(ConfigDirectory!, GetBackupSettingsFilename(version));
private Task StoreCurrentVersionBackup(Data settingsData) =>
this.StoreSerializedVersionBackup(settingsData.Version, JsonSerializer.Serialize(settingsData, JSON_OPTIONS));
/// <summary>
/// Writes the backup file from settings which were serialized already.
/// </summary>
/// <remarks>
/// The store hands the same JSON to this method and to the one writing the settings file, so
/// that both files say the same thing. Serializing twice cannot promise that: the configuration
/// data may well have changed in between, and the backup would then describe a state the
/// settings file never had.
/// </remarks>
/// <param name="settingsVersion">The version the serialized settings carry.</param>
/// <param name="settingsJson">The serialized settings.</param>
private async Task StoreSerializedVersionBackup(Version settingsVersion, string settingsJson)
{
if(settingsVersion != CURRENT_SETTINGS_VERSION)
{
this.logger.LogWarning($"Skipping settings backup because the settings version '{settingsVersion}' is not the current version '{CURRENT_SETTINGS_VERSION}'.");
return;
}
var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION);
await this.StoreSerializedSettings(settingsJson, backupSettingsPath);
this.logger.LogInformation($"Stored the settings backup file '{backupSettingsPath}'.");
}
private Task StoreSettingsSnapshot(Data settingsData, string settingsPath) =>
this.StoreSerializedSettings(JsonSerializer.Serialize(settingsData, JSON_OPTIONS), settingsPath);
/// <summary>
/// Writes settings which were serialized already to the given path.
/// </summary>
/// <param name="settingsJson">The serialized settings.</param>
/// <param name="settingsPath">The file to write them to.</param>
private async Task StoreSerializedSettings(string settingsJson, string settingsPath)
{
if(!Directory.Exists(ConfigDirectory))
{
this.logger.LogInformation("Creating the configuration directory.");
Directory.CreateDirectory(ConfigDirectory!);
}
//
// 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}'.");
}
public void InjectSpellchecking(Dictionary<string, object?> attributes) => attributes["spellcheck"] = this.ConfigurationData.App.EnableSpellchecking ? "true" : "false";
public ConfidenceLevel GetMinimumConfidenceLevel(Tools.Components component)
{
var minimumLevel = ConfidenceLevel.NONE;
var enforceGlobalMinimumConfidence = this.ConfigurationData.Confidence is { EnforceGlobalMinimumConfidence: true, GlobalMinimumConfidence: not ConfidenceLevel.NONE and not ConfidenceLevel.UNKNOWN };
if (enforceGlobalMinimumConfidence)
minimumLevel = this.ConfigurationData.Confidence.GlobalMinimumConfidence;
var componentMinimumLevel = component.MinimumConfidence(this);
if (componentMinimumLevel > minimumLevel)
minimumLevel = componentMinimumLevel;
return minimumLevel;
}
/// <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) || PluginFactory.IsAssistantActivationEnforced(plugin.Id);
/// <summary>
/// Returns the active language plugin.
/// </summary>
/// <returns>The active language plugin.</returns>
public async Task<ILanguagePlugin> GetActiveLanguagePlugin()
{
switch (this.ConfigurationData.App.LanguageBehavior)
{
case LangBehavior.AUTO:
var languageCode = await this.rustService.ReadUserLanguage();
var languagePlugins = PluginFactory.RunningPlugins.OfType<ILanguagePlugin>().ToList();
if (!string.IsNullOrWhiteSpace(languageCode))
{
var exactMatch = languagePlugins.FirstOrDefault(x => string.Equals(x.IETFTag, languageCode, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
return exactMatch;
var primaryLanguage = GetPrimaryLanguage(languageCode);
if (!string.IsNullOrWhiteSpace(primaryLanguage))
{
var primaryLanguageMatch = languagePlugins
.Where(x => string.Equals(GetPrimaryLanguage(x.IETFTag), primaryLanguage, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.IETFTag, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
if (primaryLanguageMatch is not null)
{
this.logger.LogWarning($"No exact language plugin found for '{languageCode}'. Use language fallback '{primaryLanguageMatch.IETFTag}'.");
return primaryLanguageMatch;
}
}
}
this.logger.LogWarning($"The language plugin for the language '{languageCode}' (normalized='{languageCode}') is not available.");
return PluginFactory.BaseLanguage;
case LangBehavior.MANUAL:
var pluginId = this.ConfigurationData.App.LanguagePluginId;
var plugin = PluginFactory.RunningPlugins.FirstOrDefault(x => x.Id == pluginId);
if (plugin is null)
{
this.logger.LogWarning($"The chosen language plugin (id='{pluginId}') is not available.");
return PluginFactory.BaseLanguage;
}
if (plugin is ILanguagePlugin chosenLangPlugin)
return chosenLangPlugin;
this.logger.LogError("The chosen language plugin is not a language plugin.");
return PluginFactory.BaseLanguage;
}
this.logger.LogError("The language behavior is unknown.");
return PluginFactory.BaseLanguage;
}
private static string GetPrimaryLanguage(string localeTag)
{
if (string.IsNullOrWhiteSpace(localeTag))
return string.Empty;
var separatorIndex = localeTag.IndexOf('-');
if (separatorIndex < 0)
return localeTag;
return localeTag[..separatorIndex];
}
public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false)
{
var minimumLevel = this.GetMinimumConfidenceLevel(component);
// When there is only one provider, and it has a confidence level that is high enough, we return it:
if (this.ConfigurationData.Providers.Count == 1 && this.IsProviderUsable(this.ConfigurationData.Providers[0].UsedLLMProvider, minimumLevel))
return this.ConfigurationData.Providers[0];
// Is there a current provider with a sufficiently high confidence level?
var currentProvider = Provider.NONE;
if (currentProviderId is not null && !string.IsNullOrWhiteSpace(currentProviderId))
{
var currentProviderProbe = this.ConfigurationData.Providers.FirstOrDefault(x => x.Id == currentProviderId);
if (currentProviderProbe is not null && this.IsProviderUsable(currentProviderProbe.UsedLLMProvider, minimumLevel))
currentProvider = currentProviderProbe;
}
// Is there a component-preselected provider with a sufficiently high confidence level?
var preselectedProvider = Provider.NONE;
var preselectedProviderProbe = component.PreselectedProvider(this);
if(preselectedProviderProbe != Provider.NONE && this.IsProviderUsable(preselectedProviderProbe.UsedLLMProvider, minimumLevel))
preselectedProvider = preselectedProviderProbe;
//
// Case: The preselected provider should be used before the current provider,
// and the preselected provider is available and has a confidence level
// that is high enough.
//
if(usePreselectionBeforeCurrentProvider && preselectedProvider != Provider.NONE)
return preselectedProvider;
//
// Case: The current provider is available and has a confidence level that is
// high enough.
//
if(currentProvider != Provider.NONE)
return currentProvider;
//
// Case: The current provider should be used before the preselected provider,
// but the current provider is not available or does not have a confidence
// level that is high enough. The preselected provider is available and
// has a confidence level that is high enough.
//
if(preselectedProvider != Provider.NONE)
return preselectedProvider;
// When there is an app-wide preselected provider, and it has a confidence level that is high enough, we return it:
return this.ConfigurationData.Providers.FirstOrDefault(x => x.Id == this.ConfigurationData.App.PreselectedProvider && this.IsProviderUsable(x.UsedLLMProvider, minimumLevel)) ?? Provider.NONE;
}
public Provider GetChatProviderForLoadedChat(string? chatProviderId = null)
{
var minimumLevel = this.GetMinimumConfidenceLevel(Tools.Components.CHAT);
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)
{
if (string.IsNullOrWhiteSpace(providerId))
return null;
var provider = this.ConfigurationData.Providers.FirstOrDefault(x => x.Id == providerId);
return provider is not null && IsSelectableProvider(provider) ? provider : null;
}
bool IsSelectableProvider(Provider provider) =>
provider != Provider.NONE
&& provider.UsedLLMProvider != LLMProviders.NONE
&& this.IsProviderUsable(provider.UsedLLMProvider, minimumLevel);
}
/// <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 and leave out the providers the organization
/// prohibited. This method returns those as well, so that the settings can mark them.
/// </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();
/// <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 or the organization prohibited
/// it. 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;
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>
/// <remarks>
/// A provider the organization prohibited never does, see IsProviderUsable.
/// </remarks>
/// <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 this.IsProviderUsable(provider.UsedLLMProvider, this.GetEffectiveMinimumConfidenceLevel(component, explicitMinimum));
}
/// <summary>
/// Checks whether the organization prohibited the given provider.
/// </summary>
/// <remarks>
/// A prohibition is a rule of its own, next to confidence and data-source security: no confidence
/// level and no trust by configuration makes a prohibited provider usable. It also makes the provider
/// untrusted, see GetConfiguredConfidenceLevel, but not the other way around. It names the operator
/// of an API, so a hub or a self-hosted server which serves the same models stays allowed.
/// </remarks>
/// <param name="provider">The provider to check, for LLMs, embeddings, and transcriptions alike.</param>
/// <returns>True, when the organization prohibited the provider. NONE is never prohibited.</returns>
public bool IsProviderProhibited(LLMProviders provider) => provider is not LLMProviders.NONE && this.ConfigurationData.App.ProhibitedProviders.Contains(provider);
/// <summary>
/// Checks whether the given provider may be used where the given minimum confidence level applies.
/// </summary>
/// <remarks>
/// The one check for every place which picks a provider: the organization did not prohibit it, and
/// its confidence level reaches the minimum. A prohibited provider is untrusted, see
/// GetConfiguredConfidenceLevel, but comparing the confidence level alone would still let it through
/// wherever no minimum is enforced, since even untrusted reaches the minimum NONE.
/// </remarks>
/// <param name="provider">The provider to check, for LLMs, embeddings, and transcriptions alike.</param>
/// <param name="minimumLevel">The minimum confidence level which applies.</param>
/// <returns>True, when the provider may be used, false otherwise.</returns>
public bool IsProviderUsable(LLMProviders provider, ConfidenceLevel minimumLevel) => !this.IsProviderProhibited(provider) && provider.GetConfidence(this).Level >= minimumLevel;
/// <summary>
/// Returns the prohibited providers which have a level of their own in the custom confidence scheme.
/// </summary>
/// <remarks>
/// The prohibition outranks these levels, see GetConfiguredConfidenceLevel.
/// </remarks>
/// <returns>The prohibited providers with a custom confidence level, in no particular order.</returns>
public IEnumerable<LLMProviders> GetProhibitedProvidersWithCustomConfidenceLevel() => this.ConfigurationData.Confidence.CustomConfidenceScheme.Keys.Where(this.IsProviderProhibited);
/// <summary>
/// Returns all providers that satisfy the minimum confidence level of the given component.
/// </summary>
/// <remarks>
/// The providers the organization prohibited are left out, see IsProviderUsable.
/// </remarks>
/// <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 && this.IsProviderUsable(provider.UsedLLMProvider, 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)
{
var preselection = component.GetProfilePreselection(this);
if (preselection.DoNotPreselectProfile)
return Profile.NO_PROFILE;
if (preselection.UseSpecificProfile)
return this.GetProfileById(preselection.SpecificProfileId);
var appPreselection = ProfilePreselection.FromStoredValue(this.ConfigurationData.App.PreselectedProfile);
if (appPreselection.DoNotPreselectProfile || !appPreselection.UseSpecificProfile)
return Profile.NO_PROFILE;
return this.GetProfileById(appPreselection.SpecificProfileId);
}
public Profile GetAppPreselectedProfile()
{
var appPreselection = ProfilePreselection.FromStoredValue(this.ConfigurationData.App.PreselectedProfile);
if (appPreselection.DoNotPreselectProfile || !appPreselection.UseSpecificProfile)
return Profile.NO_PROFILE;
return this.GetProfileById(appPreselection.SpecificProfileId);
}
public ChatTemplate GetPreselectedChatTemplate(Tools.Components component)
{
var preselection = component.PreselectedChatTemplate(this);
if (preselection != ChatTemplate.NO_CHAT_TEMPLATE)
return preselection;
return this.GetChatTemplateById(this.ConfigurationData.App.PreselectedChatTemplate);
}
/// <summary>
/// Returns the profile a chat template preselects for the chats started with it.
/// </summary>
/// <remarks>
/// Null whenever the template has nothing to say which could be used: it names no profile, it
/// does not allow profiles, or the profile it names is gone. The chat then keeps the profile it
/// would have without the template. Starting without a profile is an answer of its own, and it
/// comes back as Profile.NO_PROFILE.
/// </remarks>
/// <param name="chatTemplate">The chat template of the chat about to start.</param>
/// <returns>The profile to start with, or null to keep the usual one.</returns>
public Profile? GetChatTemplateProfile(ChatTemplate chatTemplate)
{
if (!chatTemplate.AllowProfileUsage || chatTemplate.ProfileId is null)
return null;
if (chatTemplate.StartsWithoutProfile())
return Profile.NO_PROFILE;
var profile = this.GetProfileById(chatTemplate.ProfileId);
return profile == Profile.NO_PROFILE ? null : profile;
}
/// <summary>
/// Returns the profile a chat started with the given chat template begins with.
/// </summary>
/// <remarks>
/// Null when the template says nothing about profiles, so the chat keeps the profile it has.
/// A template whose profile is gone starts its chats with the default profile of the chat
/// settings, which may be none — not with whatever profile the previous chat happened to use,
/// which would be a choice nobody made for this chat.
/// </remarks>
/// <param name="chatTemplate">The chat template of the chat about to start.</param>
/// <returns>The profile to start with, or null to keep the current one.</returns>
public Profile? GetChatTemplateStartProfile(ChatTemplate chatTemplate)
{
if (!chatTemplate.AllowProfileUsage || chatTemplate.ProfileId is null)
return null;
return this.GetChatTemplateProfile(chatTemplate) ?? this.GetPreselectedProfile(Tools.Components.CHAT);
}
/// <summary>
/// Returns the provider a chat template preselects for the chats started with it.
/// </summary>
/// <remarks>
/// The template is no way around the confidence levels: a provider which may not be used for
/// chats comes back as Provider.NONE, just like one which is gone or one the
/// template does not name at all. The chat then keeps the provider it would have without the
/// template.
/// </remarks>
/// <param name="chatTemplate">The chat template of the chat about to start.</param>
/// <returns>The provider to start with, or Provider.NONE to keep the usual one.</returns>
public Provider GetChatTemplateProvider(ChatTemplate chatTemplate) => this.GetChatTemplateProvider(chatTemplate.ProviderId);
/// <summary>
/// Returns the provider a chat template may preselect under the given ID.
/// </summary>
/// <remarks>
/// The one rule for the provider of a chat template: it exists and meets the confidence level
/// required for chats. The chat, assistant chat launchers, the chat template dialog, and the
/// import of a chat template all ask here, so that none of them accepts a provider another
/// one would leave out.
/// </remarks>
/// <param name="providerId">The ID the chat template names, or null when it names none.</param>
/// <returns>The provider, or Provider.NONE when the ID names no provider a chat may use.</returns>
public Provider GetChatTemplateProvider(string? providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
return Provider.NONE;
var provider = this.GetProviderById(providerId);
return provider != Provider.NONE && this.IsProviderConfident(provider, Tools.Components.CHAT) ? provider : Provider.NONE;
}
/// <summary>
/// Returns the profile a chat begins with when it starts with the given chat template now.
/// </summary>
/// <remarks>
/// A template which names a profile decides, see GetChatTemplateStartProfile. One
/// which says nothing keeps the current profile, with one exception: when that profile is the one
/// the previous template preselected, the chat starts with the default profile of the chat
/// settings instead. Keeping it would carry the choice of another template over into this chat.
/// </remarks>
/// <param name="chatTemplate">The chat template of the chat about to start.</param>
/// <param name="previousChatTemplate">The chat template the current profile was chosen with; the same one when the template does not change.</param>
/// <param name="currentProfile">The profile the chat has right now.</param>
/// <returns>The profile to start with.</returns>
public Profile ChooseChatTemplateProfile(ChatTemplate chatTemplate, ChatTemplate previousChatTemplate, Profile currentProfile)
{
if (this.GetChatTemplateStartProfile(chatTemplate) is { } templateProfile)
return templateProfile;
if (this.GetChatTemplateStartProfile(previousChatTemplate) is { } previousTemplateProfile && previousTemplateProfile.Id == currentProfile.Id)
return this.GetPreselectedProfile(Tools.Components.CHAT);
return currentProfile;
}
/// <summary>
/// Returns the provider a chat begins with when it starts with the given chat template now.
/// </summary>
/// <remarks>
/// The same as for the profile, see ChooseChatTemplateProfile: a template which
/// names a usable provider decides, and the provider of the previous template is not carried
/// over. In its place comes the default provider of the chat settings. Without such a default,
/// the current provider stays, so that the chat does not lose its provider altogether.
/// </remarks>
/// <param name="chatTemplate">The chat template of the chat about to start.</param>
/// <param name="previousChatTemplate">The chat template the current provider was chosen with; the same one when the template does not change.</param>
/// <param name="currentProvider">The provider the chat has right now.</param>
/// <returns>The provider to start with.</returns>
public Provider ChooseChatTemplateProvider(ChatTemplate chatTemplate, ChatTemplate previousChatTemplate, Provider currentProvider)
{
var templateProvider = this.GetChatTemplateProvider(chatTemplate);
if (templateProvider != Provider.NONE)
return templateProvider;
var previousTemplateProvider = this.GetChatTemplateProvider(previousChatTemplate);
if (previousTemplateProvider == Provider.NONE || previousTemplateProvider.Id != currentProvider.Id)
return currentProvider;
var defaultProvider = this.GetPreselectedProvider(Tools.Components.CHAT);
return defaultProvider == Provider.NONE ? currentProvider : defaultProvider;
}
/// <summary>
/// Returns the name of the workspace a chat template keeps the chats started with it in.
/// </summary>
/// <remarks>
/// Null when the template names none, and also while workspaces are switched off: a chat then
/// belongs to no workspace, whatever its template says.
/// </remarks>
/// <param name="chatTemplate">The chat template of the chat about to start.</param>
/// <returns>The trimmed name of the workspace, or null to keep the chat where it would go anyway.</returns>
public string? GetChatTemplateWorkspaceName(ChatTemplate chatTemplate)
{
if (string.IsNullOrWhiteSpace(chatTemplate.WorkspaceName) || this.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.DISABLE_WORKSPACES)
return null;
return chatTemplate.WorkspaceName.Trim();
}
public Profile GetProfileById(string? profileId)
{
if (string.IsNullOrWhiteSpace(profileId))
return Profile.NO_PROFILE;
if (string.Equals(profileId, Profile.NO_PROFILE.Id, StringComparison.OrdinalIgnoreCase))
return Profile.NO_PROFILE;
return this.ConfigurationData.Profiles.FirstOrDefault(x => x.Id.Equals(profileId, StringComparison.OrdinalIgnoreCase)) ?? Profile.NO_PROFILE;
}
public ChatTemplate GetChatTemplateById(string? chatTemplateId)
{
if (string.IsNullOrWhiteSpace(chatTemplateId))
return ChatTemplate.NO_CHAT_TEMPLATE;
if (string.Equals(chatTemplateId, ChatTemplate.NO_CHAT_TEMPLATE.Id, StringComparison.OrdinalIgnoreCase))
return ChatTemplate.NO_CHAT_TEMPLATE;
return this.ConfigurationData.ChatTemplates.FirstOrDefault(x => x.Id.Equals(chatTemplateId, StringComparison.OrdinalIgnoreCase)) ?? ChatTemplate.NO_CHAT_TEMPLATE;
}
public bool AreToolsEnabled() => this.ConfigurationData.Tools.EnableTools;
/// <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 collection needs, and where that value came from.
/// </summary>
/// <remarks>
/// Which IDs stand for the collection and its default are passed in rather than looked up here.
/// Both belong to the definitions, and the definitions live in the tool registry — which already
/// depends on this class, so asking it back would be a circle.<br/><br/>
/// When several of the IDs carry a level, the strictest one wins: an administrator who raised
/// the level of one tool of a collection must not find it lowered by the entry of another one.
/// For the same reason, an invalid managed level anywhere among them asks for HIGH.
/// </remarks>
/// <param name="settingsIds">The IDs which stand for the collection: its own, then those of its tools.</param>
/// <param name="defaultLevel">The collection's own minimum, used when nothing overrides it.</param>
public ToolMinimumProviderConfidenceResolution GetMinimumProviderConfidenceResolution(IReadOnlyList<string> settingsIds, ConfidenceLevel defaultLevel)
{
if (ManagedConfiguration.TryGet(x => x.Tools, x => x.MinimumProviderConfidenceByToolId, out var configMeta) && configMeta.IsLocked)
{
var managedValues = configMeta.GetValue();
ConfidenceLevel? strictestManagedLevel = null;
foreach (var settingsId in settingsIds)
{
if (!managedValues.TryGetValue(settingsId, out var configuredManagedLevel))
continue;
if (!TryParseMinimumProviderConfidence(configuredManagedLevel, out var managedConfidenceLevel))
{
this.logger.LogError(
"Managed minimum provider confidence '{ConfiguredLevel}' for tool '{ToolId}' is invalid. Requiring HIGH as a safe fallback.",
configuredManagedLevel,
settingsId);
return new(ConfidenceLevel.HIGH, "invalid managed config; safe fallback");
}
if (strictestManagedLevel is null || managedConfidenceLevel > strictestManagedLevel)
strictestManagedLevel = managedConfidenceLevel;
}
if (strictestManagedLevel is { } managedLevel)
return new(managedLevel, "managed config");
}
ConfidenceLevel? strictestStoredLevel = null;
foreach (var settingsId in settingsIds)
{
if (this.ConfigurationData.Tools.MinimumProviderConfidenceByToolId.TryGetValue(settingsId, out var configuredLevel) &&
TryParseMinimumProviderConfidence(configuredLevel, out var confidenceLevel) &&
(strictestStoredLevel is null || confidenceLevel > strictestStoredLevel))
{
strictestStoredLevel = confidenceLevel;
}
}
if (strictestStoredLevel is { } storedLevel)
return new(storedLevel, "stored override");
return new(defaultLevel, "default fallback");
}
private static bool TryParseMinimumProviderConfidence(string? configuredLevel, out ConfidenceLevel confidenceLevel) =>
Enum.TryParse(configuredLevel, true, out confidenceLevel) &&
Enum.IsDefined(confidenceLevel) &&
confidenceLevel is not ConfidenceLevel.UNKNOWN;
/// <summary>
/// Stores which provider confidence a tool collection needs.
/// </summary>
/// <remarks>
/// Stored under the ID of the collection. Entries under the IDs of its tools are removed, since
/// the strictest entry wins and an older one would otherwise outvote the level just chosen.
/// </remarks>
/// <param name="settingsIds">The IDs which stand for the collection: its own, then those of its tools.</param>
/// <param name="confidenceLevel">The level the user chose.</param>
/// <param name="defaultLevel">The collection's own minimum. Choosing it again removes the override.</param>
public void SetMinimumProviderConfidence(IReadOnlyList<string> settingsIds, ConfidenceLevel confidenceLevel, ConfidenceLevel defaultLevel)
{
foreach (var settingsId in settingsIds)
this.ConfigurationData.Tools.MinimumProviderConfidenceByToolId.Remove(settingsId);
if (confidenceLevel != defaultLevel)
this.ConfigurationData.Tools.MinimumProviderConfidenceByToolId[settingsIds[0]] = confidenceLevel.ToString();
}
/// <summary>
/// Returns the confidence level of the given provider.
/// </summary>
/// <remarks>
/// A prohibition by the organization comes first: a prohibited provider is always untrusted, no matter
/// what the confidence scheme says, and no matter which level the user or a configuration plugin chose
/// for it in the custom scheme. Those levels stay stored, so they apply again once the prohibition ends.
/// After that, the confidence scheme decides, and in the custom scheme, the level chosen for the provider.
/// </remarks>
/// <param name="llmProvider">The provider, for LLMs, embeddings, and transcriptions alike.</param>
/// <returns>The confidence level of the provider.</returns>
public ConfidenceLevel GetConfiguredConfidenceLevel(LLMProviders llmProvider)
{
if(llmProvider is LLMProviders.NONE)
return ConfidenceLevel.NONE;
if (this.IsProviderProhibited(llmProvider))
return ConfidenceLevel.UNTRUSTED;
switch (this.ConfigurationData.Confidence.ConfidenceScheme)
{
case ConfidenceSchemes.TRUST_ALL:
return llmProvider switch
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
_ => ConfidenceLevel.MEDIUM,
};
case ConfidenceSchemes.TRUST_USA_EUROPE:
return llmProvider switch
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
LLMProviders.DEEP_SEEK => ConfidenceLevel.LOW,
LLMProviders.ALIBABA_CLOUD => ConfidenceLevel.LOW,
_ => ConfidenceLevel.MEDIUM,
};
case ConfidenceSchemes.TRUST_USA:
return llmProvider switch
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
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,
};
case ConfidenceSchemes.TRUST_EUROPE:
return llmProvider switch
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
LLMProviders.MISTRAL => ConfidenceLevel.MEDIUM,
LLMProviders.HELMHOLTZ => ConfidenceLevel.MEDIUM,
LLMProviders.GWDG => ConfidenceLevel.MEDIUM,
LLMProviders.HETZNER => ConfidenceLevel.MEDIUM,
LLMProviders.IONOS => ConfidenceLevel.MEDIUM,
_ => ConfidenceLevel.LOW,
};
case ConfidenceSchemes.TRUST_ASIA:
return llmProvider switch
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
LLMProviders.DEEP_SEEK => ConfidenceLevel.MEDIUM,
LLMProviders.ALIBABA_CLOUD => ConfidenceLevel.MEDIUM,
_ => ConfidenceLevel.LOW,
};
case ConfidenceSchemes.LOCAL_TRUST_ONLY:
return llmProvider switch
{
LLMProviders.SELF_HOSTED => ConfidenceLevel.HIGH,
_ => ConfidenceLevel.VERY_LOW,
};
case ConfidenceSchemes.CUSTOM:
return this.ConfigurationData.Confidence.CustomConfidenceScheme.GetValueOrDefault(llmProvider, ConfidenceLevel.UNKNOWN);
default:
return ConfidenceLevel.UNKNOWN;
}
}
public static string ToSettingName<TIn, TOut>(Expression<Func<TIn, TOut>> propertyExpression)
{
MemberExpression? memberExpr;
// Handle the case where the expression is a unary expression (e.g., when using Convert):
if (propertyExpression.Body is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpr)
memberExpr = unaryExpr.Operand as MemberExpression;
else
memberExpr = propertyExpression.Body as MemberExpression;
if (memberExpr is null)
throw new ArgumentException("Expression must be a property access", nameof(propertyExpression));
// Return the full name of the property, including the class name:
return $"{typeof(TIn).Name}.{memberExpr.Member.Name}";
}
}