using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.Services; using Lua; namespace AIStudio.Tools.PluginSystem; /// /// Represents metadata for a configuration object from a configuration plugin. These are /// complex objects such as configured LLM providers, chat templates, etc. /// public sealed record PluginConfigurationObject { private static RustService RustService => Program.SERVICE_PROVIDER.GetRequiredService(); private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService(); private static ThreadSafeRandom Rng => Program.SERVICE_PROVIDER.GetRequiredService(); private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(); /// /// The id of the configuration plugin to which this configuration object belongs. /// public required Guid ConfigPluginId { get; init; } = Guid.NewGuid(); /// /// The id of the configuration object, e.g., the id of a chat template. /// public required Guid Id { get; init; } = Guid.NewGuid(); /// /// The type of the configuration object. /// public required PluginConfigurationObjectType Type { get; init; } = PluginConfigurationObjectType.NONE; /// /// The name of the configuration object, e.g. the name of a provider. /// public string Name { get; init; } = string.Empty; /// /// Where this configuration object sends data to: the host of a self-hosted provider or data /// source, or the name of the cloud provider. Empty for objects without a destination, such as /// chat templates or profiles. /// /// /// We keep this next to the object metadata so the import preview can tell users where a /// configuration would send their prompts before its providers are stored. /// public string Endpoint { get; private init; } = string.Empty; /// /// Determines the destination of a configuration object for the import preview. /// private static string DescribeEndpoint(IConfigurationObject configObject) => configObject switch { Settings.Provider { IsSelfHosted: true } provider => provider.Hostname, Settings.Provider provider => Provider.LLMProvidersExtensions.ToName(provider.UsedLLMProvider), EmbeddingProvider { IsSelfHosted: true } embeddingProvider => embeddingProvider.Hostname, EmbeddingProvider embeddingProvider => Provider.LLMProvidersExtensions.ToName(embeddingProvider.UsedLLMProvider), TranscriptionProvider { IsSelfHosted: true } transcriptionProvider => transcriptionProvider.Hostname, TranscriptionProvider transcriptionProvider => Provider.LLMProvidersExtensions.ToName(transcriptionProvider.UsedLLMProvider), DataSourceERI_V1 dataSource => dataSource.Hostname, _ => string.Empty, }; /// /// The tokenizer file as the configuration plugin names it, relative to the plugin directory. /// Empty for objects without a tokenizer. /// /// /// The provider in the settings does not keep this path: it points to the copy the runtime stored /// of the file. The synchronization of the tokenizers needs both, this path to know which file the /// plugin wants, and the copy to know what is stored already. /// public string ConfiguredTokenizerPath { get; private init; } = string.Empty; /// /// Reads the tokenizer path of a configuration object just parsed from a configuration plugin. /// private static string DescribeConfiguredTokenizerPath(IConfigurationObject configObject) => configObject switch { Settings.Provider provider => provider.TokenizerPath, EmbeddingProvider embeddingProvider => embeddingProvider.TokenizerPath, _ => string.Empty, }; /// /// Hands the synchronized tokenizer of the stored provider over to the provider just parsed from /// the configuration plugin. /// /// /// The plugin names its tokenizer by a path inside the plugin, while the stored provider points to /// the copy the runtime made of it, along with the fingerprint of that copy. Until the tokenizers /// are synchronized, the settings have to keep pointing to that copy: the embedding service does /// not wait for the plugins, and an indexing run in between built the embedding signature from an /// empty fingerprint. It took that for another tokenizer and reset the index of every data source /// of the provider, without asking. A new provider starts without a tokenizer until the /// synchronization stores one. /// /// The configuration object just parsed from the plugin. /// The configuration object stored so far, or null for a new one. /// The parsed configuration object, carrying the tokenizer of the stored one. private static ConfigurationBaseObject KeepSynchronizedTokenizer(ConfigurationBaseObject parsedObject, ConfigurationBaseObject? storedObject) => (parsedObject, storedObject) switch { (Settings.Provider provider, Settings.Provider storedProvider) => provider with { TokenizerPath = storedProvider.TokenizerPath }, (Settings.Provider provider, _) => provider with { TokenizerPath = string.Empty }, (EmbeddingProvider provider, EmbeddingProvider storedProvider) => provider with { TokenizerPath = storedProvider.TokenizerPath, TokenizerFingerprint = storedProvider.TokenizerFingerprint }, (EmbeddingProvider provider, _) => provider with { TokenizerPath = string.Empty, TokenizerFingerprint = string.Empty }, _ => parsedObject, }; /// /// Parses Lua table entries into configuration objects of the specified type, populating the /// provided list with results. /// /// The type of configuration object to parse, which must /// inherit from . /// The type of configuration object to process, as specified /// in . /// An expression to retrieve existing configuration objects from /// the main configuration data. /// An expression to retrieve the next available configuration /// object number from the main configuration data. /// The Lua table containing entries to parse into configuration objects. /// The unique identifier of the plugin associated with the configuration /// objects being parsed. /// The list to populate with the parsed configuration objects. /// This parameter is passed by reference. /// Specifies whether to perform the operation as a dry run, where changes /// are not persisted. /// An optional parameter specifying the file path of the plugin, used for relative paths in the Lua table. /// Returns true if parsing succeeds and configuration objects are added /// to the list; otherwise, false. public static bool TryParse( PluginConfigurationObjectType configObjectType, Expression>> configObjectSelection, Expression> nextConfigObjectNumSelection, LuaTable mainTable, Guid configPluginId, ref List configObjects, bool dryRun, string pluginPath = "" ) where TClass : ConfigurationBaseObject { var luaTableName = configObjectType switch { PluginConfigurationObjectType.LLM_PROVIDER => "LLM_PROVIDERS", PluginConfigurationObjectType.CHAT_TEMPLATE => "CHAT_TEMPLATES", PluginConfigurationObjectType.DATA_SOURCE => "DATA_SOURCES", PluginConfigurationObjectType.EMBEDDING_PROVIDER => "EMBEDDING_PROVIDERS", PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => "TRANSCRIPTION_PROVIDERS", PluginConfigurationObjectType.PROFILE => "PROFILES", PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => "DOCUMENT_ANALYSIS_POLICIES", _ => null, }; if (luaTableName is null) { LOG.LogError("The configuration object type '{ConfigObjectType}' is not supported yet (config plugin id: {ConfigPluginId}).", configObjectType, configPluginId); return false; } if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead(out var luaTable)) { LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId); return false; } var localSettingsManager = SettingsManagerAccess; var storedObjects = configObjectSelection.Compile()(localSettingsManager.ConfigurationData); var numberObjects = luaTable.ArrayLength; ThreadSafeRandom? random = null; for (var i = 1; i <= numberObjects; i++) { var luaObjectTableValue = luaTable[i]; if (!luaObjectTableValue.TryRead(out var luaObjectTable)) { LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId); continue; } var (wasParsingSuccessful, configObject) = configObjectType switch { PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject), PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject), PluginConfigurationObjectType.PROFILE => (Profile.TryParseProfileTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Profile.NO_PROFILE, configurationObject), PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject), PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject), PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => (DataDocumentAnalysisPolicy.TryProcessConfiguration(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject is DataDocumentAnalysisPolicy, configurationObject), _ => (false, NoConfigurationObject.INSTANCE) }; if (wasParsingSuccessful) { // Store it in the config object list: configObjects.Add(new() { ConfigPluginId = configPluginId, Id = Guid.Parse(configObject.Id), Type = configObjectType, Name = configObject.Name, Endpoint = DescribeEndpoint(configObject), ConfiguredTokenizerPath = DescribeConfiguredTokenizerPath(configObject), }); if (dryRun) continue; var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id); // Case: The object already exists, we update it: if (objectIndex > -1) { var existingObject = storedObjects[objectIndex]; if (!MayReplaceConfigurationObject(existingObject, configPluginId)) continue; configObject = KeepSynchronizedTokenizer(configObject, existingObject) with { Num = existingObject.Num }; storedObjects[objectIndex] = (TClass)configObject; } // Case: The object does not exist, we have to add it else { configObject = KeepSynchronizedTokenizer(configObject, null); if (nextConfigObjectNumSelection.TryIncrement(localSettingsManager.ConfigurationData, IncrementType.POST) is { Success: true, UpdatedValue: var nextNum }) { // Case: Increment the next number was successful configObject = configObject with { Num = nextNum }; storedObjects.Add((TClass)configObject); } else { // Case: The next number could not be incremented, we use a random number random ??= Rng; configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) }; storedObjects.Add((TClass)configObject); LOG.LogWarning("The next number for the configuration object '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId); } } } else LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid configuration object (type={ConfigObjectType}, config plugin id: {ConfigPluginId}).", luaTableName, i, configObjectType, configPluginId); } return true; } /// /// Stores the tokenizers the providers of a configuration plugin name, and points these providers /// in the settings to the stored copies. /// /// The configuration plugin whose providers to synchronize. /// The directory of the plugin, which the tokenizer paths are relative to. /// The configuration objects the plugin defined while it started. They carry the tokenizer path of each provider. /// True when a provider in the settings changed; otherwise false. [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "Tokenizer synchronization needs indexed access to update enterprise-managed providers in place.")] public static async Task SyncManagedTokenizersAsync(Guid configPluginId, string pluginPath, IList configObjects) { var wasConfigurationChanged = false; var localSettingsManager = SettingsManagerAccess; ITokenizerStorage tokenizerStorage = RustService; for (var i = 0; i < localSettingsManager.ConfigurationData.Providers.Count; i++) { var provider = localSettingsManager.ConfigurationData.Providers[i]; if (!provider.IsEnterpriseConfiguration || provider.EnterpriseConfigurationPluginId != configPluginId) continue; var configuredTokenizerPath = FindConfiguredTokenizerPath(PluginConfigurationObjectType.LLM_PROVIDER, provider.Id); if (configuredTokenizerPath is null) continue; var syncedProvider = await SyncProviderTokenizerAsync(tokenizerStorage, provider, configuredTokenizerPath, pluginPath); if (syncedProvider == provider) continue; localSettingsManager.ConfigurationData.Providers[i] = syncedProvider; wasConfigurationChanged = true; } for (var i = 0; i < localSettingsManager.ConfigurationData.EmbeddingProviders.Count; i++) { var provider = localSettingsManager.ConfigurationData.EmbeddingProviders[i]; if (!provider.IsEnterpriseConfiguration || provider.EnterpriseConfigurationPluginId != configPluginId) continue; var configuredTokenizerPath = FindConfiguredTokenizerPath(PluginConfigurationObjectType.EMBEDDING_PROVIDER, provider.Id); if (configuredTokenizerPath is null) continue; var syncedProvider = await SyncEmbeddingTokenizerAsync(tokenizerStorage, provider, configuredTokenizerPath, pluginPath); if (syncedProvider == provider) continue; localSettingsManager.ConfigurationData.EmbeddingProviders[i] = syncedProvider; wasConfigurationChanged = true; } return wasConfigurationChanged; // // A provider the plugin did not define this time is left as it is: the clean-up after the // start of all plugins removes it, together with its tokenizer. // string? FindConfiguredTokenizerPath(PluginConfigurationObjectType configObjectType, string providerId) => configObjects.FirstOrDefault(configObject => configObject.Type == configObjectType && configObject.ConfigPluginId == configPluginId && configObject.Id.ToString() == providerId)?.ConfiguredTokenizerPath; } /// /// Parses configured data sources from a configuration plugin. /// /// The Lua table containing entries to parse into data sources. /// The unique identifier of the plugin associated with the data sources. /// The list to populate with the parsed configuration objects. /// Specifies whether to perform the operation as a dry run. /// True if the table was present and processed; otherwise false. public static bool TryParseDataSources( LuaTable mainTable, Guid configPluginId, ref List configObjects, bool dryRun) { const string LUA_TABLE_NAME = "DATA_SOURCES"; if (!mainTable.TryGetValue(LUA_TABLE_NAME, out var luaValue) || !luaValue.TryRead(out var luaTable)) { LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, configPluginId); return false; } var localSettingsManager = SettingsManagerAccess; var storedObjects = localSettingsManager.ConfigurationData.DataSources; var numberObjects = luaTable.ArrayLength; ThreadSafeRandom? random = null; for (var i = 1; i <= numberObjects; i++) { var luaObjectTableValue = luaTable[i]; if (!luaObjectTableValue.TryRead(out var luaObjectTable)) { LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); continue; } if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, dryRun, out var configObject)) { LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); continue; } configObjects.Add(new() { ConfigPluginId = configPluginId, Id = Guid.Parse(configObject.Id), Type = PluginConfigurationObjectType.DATA_SOURCE, Name = configObject.Name, Endpoint = DescribeEndpoint(configObject), }); if (dryRun) continue; var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id); if (objectIndex > -1) { var existingObject = storedObjects[objectIndex]; if (!MayReplaceConfigurationObject(existingObject, configPluginId)) continue; configObject = configObject with { Num = existingObject.Num }; storedObjects[objectIndex] = configObject; } else { if (IncrementDataSourceNum(localSettingsManager.ConfigurationData) is { Success: true, UpdatedValue: var nextNum }) { configObject = configObject with { Num = nextNum }; storedObjects.Add(configObject); } else { random ??= Rng; configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) }; storedObjects.Add(configObject); LOG.LogWarning("The next number for the data source '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId); } } } return true; static IncrementResult IncrementDataSourceNum(Data data) { return ((Expression>)(x => x.NextDataSourceNum)).TryIncrement(data, IncrementType.POST); } } /// /// Checks whether a configuration plugin may replace a stored configuration object, or whether /// that object belongs to the IT department of an organization. /// /// /// Configuration objects are matched by their ID alone. Without this check, a local configuration /// plugin could claim the ID of an object an organization deployed and replace it, e.g. to point /// a self-hosted LLM provider at a different host.

/// 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. ///
/// The configuration object which is stored already. /// The configuration plugin which wants to replace that object. /// True when the plugin may replace the object, otherwise false. private static bool MayReplaceConfigurationObject(IConfigurationObject existingObject, Guid configPluginId) { if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId) return true; if (!PluginFactory.IsOrganizationConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId)) return true; if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId)) return true; LOG.LogWarning("The configuration plugin '{ConfigPluginId}' tried to replace the object '{ConfigObjectName}' (id={ConfigObjectId}), which belongs to the configuration plugin '{OwningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.", configPluginId, existingObject.Name, existingObject.Id, existingObject.EnterpriseConfigurationPluginId); return false; } /// /// Cleans up configuration objects of a specified type that are no longer associated with any available plugin. /// /// The type of configuration object to clean up. /// The type of configuration object to process. /// A selection expression to retrieve the configuration objects from the main configuration. /// A list of currently available plugins. /// /// The IDs of the configuration plugins which an organization deployed on this machine, including /// those which could not be loaded. Objects of a deployed plugin are never removed, because the /// plugin was not removed either. /// /// /// The IDs of the configuration plugins which were loaded, but did not start. Their objects are /// never removed either: such a plugin contributed nothing to the list of configuration objects, /// so all of its objects would look as if the plugin had dropped them. /// /// A list of all existing configuration objects. /// An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable. /// When true, delete the associated non-API-key secret from the OS keyring. /// Returns true if the configuration was altered during cleanup; otherwise, false. public static async Task CleanLeftOverConfigurationObjects( PluginConfigurationObjectType configObjectType, Expression>> configObjectSelection, IList availablePlugins, IReadOnlySet deployedEnterpriseConfigPluginIds, IReadOnlySet notStartedConfigPluginIds, IList configObjectList, SecretStoreType? secretStoreType = null, bool deleteSecret = false) where TClass : IConfigurationObject { var localSettingsManager = SettingsManagerAccess; var configuredObjects = configObjectSelection.Compile()(localSettingsManager.ConfigurationData); var leftOverObjects = new List(); foreach (var configuredObject in configuredObjects) { // Only process objects that are based on enterprise configuration plugins (aka configuration plugins), // as only those can be left over after a plugin was removed: if(!configuredObject.IsEnterpriseConfiguration) continue; // From what plugin is this configuration object coming from? var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId; if(configObjectSourcePluginId == Guid.Empty) continue; // // Is the source plugin deployed, but could not be loaded? Then we must not touch any of // its objects. The plugin was not removed, it is broken: it might be invalid Lua code, // a missing `plugin.lua`, or an incomplete download. Removing the objects would delete // the organization's providers and data sources, including their secrets, although the // organization still manages this AI Studio instance: // if(deployedEnterpriseConfigPluginIds.Contains(configObjectSourcePluginId) && availablePlugins.All(plugin => plugin.Id != configObjectSourcePluginId)) continue; // // Was the source plugin loaded, but did not start? Then it is not broken, it could not // run this time, e.g., because it ran out of time on a slow machine. It contributed no // objects, so every one of its objects would look removed from the plugin. They stay // until the plugin starts again and tells us which ones it still defines: // if(notStartedConfigPluginIds.Contains(configObjectSourcePluginId)) continue; // Is the source plugin still available? If not, we can be pretty sure that this configuration object is left // over and should be removed: var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId); if(templateSourcePlugin is null) { LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is based on a plugin that is not available anymore. Removing this object from the settings."); leftOverObjects.Add(configuredObject); } // Is the configuration object still present in the configuration plugin? If not, it is also left over and should be removed: if(!configObjectList.Any(configObject => configObject.Type == configObjectType && configObject.ConfigPluginId == configObjectSourcePluginId && configObject.Id.ToString() == configuredObject.Id)) { LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is not present in the configuration plugin anymore. Removing the object from the settings."); leftOverObjects.Add(configuredObject); } } // Remove collected items after enumeration to avoid modifying the collection during iteration: var wasConfigurationChanged = leftOverObjects.Count > 0; foreach (var item in leftOverObjects.Distinct()) { if (item is Settings.Provider provider) { var deleteTokenizerResult = await RustService.DeleteTokenizer(TokenizerModelId.ForProvider(provider)); if (!deleteTokenizerResult.Success) LOG.LogWarning("Failed to delete tokenizer for removed enterprise provider '{ProviderName}': {Issue}", provider.InstanceName, deleteTokenizerResult.Message); } else if (item is EmbeddingProvider embeddingProvider) { var deleteTokenizerResult = await RustService.DeleteTokenizer(TokenizerModelId.ForEmbeddingProvider(embeddingProvider)); if (!deleteTokenizerResult.Success) LOG.LogWarning("Failed to delete tokenizer for removed enterprise embedding provider '{ProviderName}': {Issue}", embeddingProvider.Name, deleteTokenizerResult.Message); } configuredObjects.Remove(item); // Delete the API key from the OS keyring if the removed object has one: if(deleteSecret && item is ISecretId regularSecretId) { var deleteResult = await RustService.DeleteSecret(regularSecretId, secretStoreType ?? SecretStoreType.DATA_SOURCE); if (deleteResult.Success) LOG.LogInformation($"Successfully deleted secret for removed enterprise object '{item.Name}' from the OS keyring."); else LOG.LogWarning($"Failed to delete secret for removed enterprise object '{item.Name}' from the OS keyring: {deleteResult.Issue}"); } else if(item is IUserProvidedAPIKey { AllowUserProvidedAPIKey: true }) { // The user manages their own key for this provider. Keep it in the OS keyring // in case the organization's configuration comes back later, instead of forcing // the user to re-enter it: LOG.LogInformation($"Preserving the user-provided API key for removed enterprise provider '{item.Name}' in the OS keyring."); } else if(secretStoreType is not null && item is ISecretId secretId) { var deleteResult = await RustService.DeleteAPIKey(secretId, secretStoreType.Value); if (deleteResult.Success) LOG.LogInformation($"Successfully deleted API key for removed enterprise provider '{item.Name}' from the OS keyring."); else LOG.LogWarning($"Failed to delete API key for removed enterprise provider '{item.Name}' from the OS keyring: {deleteResult.Issue}"); } } return wasConfigurationChanged; } private static async Task SyncProviderTokenizerAsync(ITokenizerStorage tokenizerStorage, Settings.Provider provider, string configuredTokenizerPath, string pluginPath) { var syncedTokenizer = await SyncTokenizerAsync( tokenizerStorage, configuredTokenizerPath, provider.TokenizerPath, pluginPath, TokenizerModelId.ForProvider(provider), $"provider '{provider.InstanceName}'"); return provider with { TokenizerPath = syncedTokenizer.Path }; } private static async Task SyncEmbeddingTokenizerAsync(ITokenizerStorage tokenizerStorage, EmbeddingProvider provider, string configuredTokenizerPath, string pluginPath) { var syncedTokenizer = await SyncTokenizerAsync( tokenizerStorage, configuredTokenizerPath, provider.TokenizerPath, pluginPath, TokenizerModelId.ForEmbeddingProvider(provider), $"embedding provider '{provider.Name}'"); // // The embedding signature is built from the tokenizer's content, so the fingerprint travels // with the provider. When neither the stored copy nor the file in the plugin could be read, // there is nothing to build it from, and writing nothing would look like another tokenizer // and cost every data source of this provider its index -- so in that case the previous // fingerprint is kept rather than cleared. // var syncedTokenizerFingerprint = syncedTokenizer.Fingerprint; if (string.IsNullOrEmpty(syncedTokenizerFingerprint) && !string.IsNullOrWhiteSpace(syncedTokenizer.Path)) syncedTokenizerFingerprint = provider.TokenizerFingerprint; return provider with { TokenizerPath = syncedTokenizer.Path, TokenizerFingerprint = syncedTokenizerFingerprint }; } /// /// Stores the tokenizer a configuration plugin names for a model, or deletes what is stored for /// the model when the plugin names none or an unusable one. A stored copy with the same content /// as the file in the plugin is kept as it is. /// /// Where tokenizers are checked and stored, the runtime outside of tests. /// The tokenizer path as the plugin names it, relative to the plugin directory. /// The copy the provider points to so far, or an empty string when it has none. /// The directory of the plugin. The tokenizer has to lie inside it. /// The model the tokenizer belongs to, as TokenizerModelId builds it. /// How the log names the provider, e.g., "provider 'Name'". /// The stored copy and the fingerprint of its content, or StoredTokenizer.NONE when no tokenizer is stored. internal static async Task SyncTokenizerAsync(ITokenizerStorage tokenizerStorage, string configuredTokenizerPath, string storedTokenizerPath, string pluginPath, string modelId, string logName) { if (string.IsNullOrWhiteSpace(configuredTokenizerPath)) { var deleteResult = await tokenizerStorage.DeleteTokenizer(modelId); if (!deleteResult.Success) LOG.LogWarning("Failed to delete tokenizer for {LogName}: {Issue}", logName, deleteResult.Message); return StoredTokenizer.NONE; } var resolvedPath = ResolvePluginTokenizerPath(configuredTokenizerPath, pluginPath); if (resolvedPath is null) { var deleteResult = await tokenizerStorage.DeleteTokenizer(modelId); if (!deleteResult.Success) LOG.LogWarning("Failed to delete tokenizer after invalid path for {LogName}: {Issue}", logName, deleteResult.Message); LOG.LogWarning("The configured tokenizer path '{TokenizerPath}' for {LogName} is invalid. The tokenizer path must stay within the plugin directory '{PluginPath}'.", configuredTokenizerPath, logName, pluginPath); return StoredTokenizer.NONE; } // // Checking a tokenizer means the runtime builds it from the whole file, which takes most of // a second for a common one, and every start of the plugin did that again for every // tokenizer. A stored copy with the same content as the file in the plugin passed that check // when it was stored, and storing it again would change nothing. Comparing the content also // notices a copy which is gone or was changed since, and two files which cannot be read are // not the same tokenizer. // var comparingStartedAt = Stopwatch.GetTimestamp(); var sourceFingerprint = await TokenizerFingerprint.ForFileAsync(resolvedPath); if (!string.IsNullOrEmpty(sourceFingerprint) && !string.IsNullOrWhiteSpace(storedTokenizerPath)) { var storedFingerprint = await TokenizerFingerprint.ForFileAsync(storedTokenizerPath); if (storedFingerprint == sourceFingerprint) { LOG.LogInformation("The tokenizer for {LogName} is unchanged; kept the stored copy (checked in {Milliseconds:F0} ms).", logName, Stopwatch.GetElapsedTime(comparingStartedAt).TotalMilliseconds); return new StoredTokenizer(storedTokenizerPath, storedFingerprint); } } var validateResult = await tokenizerStorage.ValidateTokenizer(resolvedPath); if (!validateResult.Success) { var deleteResult = await tokenizerStorage.DeleteTokenizer(modelId); if (!deleteResult.Success) LOG.LogWarning("Failed to delete tokenizer after validation failure for {LogName}: {Issue}", logName, deleteResult.Message); LOG.LogWarning("The configured tokenizer for {LogName} is invalid. Path='{TokenizerPath}', issue='{Issue}'", logName, resolvedPath, validateResult.Message); return StoredTokenizer.NONE; } var storeResult = await tokenizerStorage.StoreTokenizer(modelId, resolvedPath); if (!storeResult.Success) { LOG.LogWarning("Failed to store tokenizer for {LogName}. Path='{TokenizerPath}', issue='{Issue}'", logName, resolvedPath, storeResult.Message); return StoredTokenizer.NONE; } // // The runtime copies the file as it is, so when the copy cannot be read right afterward, the // file in the plugin still tells what is in it: // var storedTokenizerFingerprint = await TokenizerFingerprint.ForFileAsync(storeResult.StoredPath); if (string.IsNullOrEmpty(storedTokenizerFingerprint)) storedTokenizerFingerprint = sourceFingerprint; return new StoredTokenizer(storeResult.StoredPath, storedTokenizerFingerprint); } private static string? ResolvePluginTokenizerPath(string configuredTokenizerPath, string pluginPath) { if (string.IsNullOrWhiteSpace(pluginPath)) return null; var fullPluginPath = Path.GetFullPath(pluginPath); var candidatePath = Path.GetFullPath(Path.Combine(fullPluginPath, configuredTokenizerPath)); if (candidatePath.Equals(fullPluginPath, StringComparison.OrdinalIgnoreCase)) return null; var pluginPrefix = fullPluginPath.EndsWith(Path.DirectorySeparatorChar) ? fullPluginPath : fullPluginPath + Path.DirectorySeparatorChar; return candidatePath.StartsWith(pluginPrefix, StringComparison.OrdinalIgnoreCase) ? candidatePath : null; } }