Fixed the tokenizer fingerprint of imported embedding providers (#1077)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-16 18:23:28 +02:00
1 parent d333720f3f
commit f5a257270e
4 files changed
+109 -1

No files matched your search

@@ -92,7 +92,8 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
/// <remarks> /// <remarks>
/// Carried through the dialog untouched as long as the user leaves the tokenizer alone. Rebuilding /// Carried through the dialog untouched as long as the user leaves the tokenizer alone. Rebuilding
/// it from the path on every open would read a file for nothing, and an unreadable one would look /// it from the path on every open would read a file for nothing, and an unreadable one would look
/// like another tokenizer and cost every data source of this provider its index. /// like another tokenizer and cost every data source of this provider its index. Only a tokenizer
/// which arrives without a fingerprint, as with an imported provider, has it read while storing.
/// </remarks> /// </remarks>
[Parameter] [Parameter]
public string DataTokenizerFingerprint { get; set; } = string.Empty; public string DataTokenizerFingerprint { get; set; } = string.Empty;
@@ -301,6 +302,16 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
if (!this.dataIsValid) if (!this.dataIsValid)
return; return;
//
// A tokenizer can arrive without a fingerprint: an imported provider brings only the path,
// because importing reads no file before the user agreed. Storing is that agreement, so the
// fingerprint is read here, from the file about to be copied, which the copy matches byte for
// byte. It has to be known before the question below, which compares the signatures. Read any
// later, it would change the signature after the user was asked, and the data sources of this
// provider would be indexed again without a word.
//
this.dataTokenizerFingerprint = await TokenizerFingerprint.KeepOrReadAsync(this.dataFilePath, this.dataTokenizerFingerprint);
// //
// Ask before anything is written. Storing a tokenizer deletes the previous one before it // Ask before anything is written. Storing a tokenizer deletes the previous one before it
// copies, and the API key goes into the OS keyring right after, so asking any later would // copies, and the API key goes into the OS keyring right after, so asking any later would
@@ -43,4 +43,28 @@ public static class TokenizerFingerprint
return string.Empty; return string.Empty;
} }
} }
/// <summary>
/// Returns the fingerprint a provider is stored with: the one already known, or else the one of its tokenizer file.
/// </summary>
/// <remarks>
/// A known fingerprint is kept without reading the file. Reading it again would cost time for
/// nothing, and an unreadable file would look like another tokenizer. A tokenizer without a
/// fingerprint, such as the one an imported provider brings along, gets one from its file, so
/// the embedding signature tells it apart from no tokenizer at all.
/// </remarks>
/// <param name="tokenizerPath">The tokenizer file about to be stored. May be empty when no tokenizer is set.</param>
/// <param name="knownFingerprint">The fingerprint known for that file so far, or an empty string.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The fingerprint, or an empty string when no tokenizer is set or its file cannot be read.</returns>
public static async Task<string> KeepOrReadAsync(string tokenizerPath, string knownFingerprint, CancellationToken token = default)
{
if (string.IsNullOrWhiteSpace(tokenizerPath))
return string.Empty;
if (!string.IsNullOrEmpty(knownFingerprint))
return knownFingerprint;
return await ForFileAsync(tokenizerPath, token);
}
} }
@@ -311,6 +311,25 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
}); });
} }
[Test]
public async Task AnImportedEmbeddingProviderIsStoredWithTheFingerprintOfItsTokenizer()
{
const PluginConfigurationObjectType TYPE = PluginConfigurationObjectType.EMBEDDING_PROVIDER;
var (original, snippet) = ExportProvider(TYPE, null, Path.Combine(this.temporaryDirectory, "tokenizer.json"));
var copy = this.CreateTokenizerCopy(TYPE, original.Id);
var imported = (EmbeddingProvider)Import(snippet, TYPE).ConfigurationObject;
// What the embedding provider dialog does while storing the imported provider:
var storedFingerprint = await TokenizerFingerprint.KeepOrReadAsync(imported.TokenizerPath, imported.TokenizerFingerprint);
var fingerprintOfTheCopiedFile = await TokenizerFingerprint.ForFileAsync(copy);
Assert.Multiple(() =>
{
Assert.That(imported.TokenizerFingerprint, Is.Empty, "The import reads no file before the user agreed to it.");
Assert.That(storedFingerprint, Is.Not.Empty.And.EqualTo(fingerprintOfTheCopiedFile), "Without the fingerprint, the embedding signature cannot tell the tokenizer apart from no tokenizer at all.");
});
}
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
public void ATokenizerWhichIsNotOnThisComputerIsLeftOut(PluginConfigurationObjectType type) public void ATokenizerWhichIsNotOnThisComputerIsLeftOut(PluginConfigurationObjectType type)
@@ -0,0 +1,54 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks which fingerprint the embedding provider dialog stores along with a tokenizer.
/// </summary>
/// <remarks>
/// The fingerprint enters the embedding signature. Storing a provider reads one only for a tokenizer
/// which arrived without it, such as the tokenizer of an imported provider. A provider which has one
/// keeps it: reading the file again would cost time for nothing, and any other answer would throw away
/// the index of every data source behind that provider.
/// </remarks>
[TestFixture]
public sealed class TokenizerFingerprintTests
{
private const string EMBEDDING_ID = "7d2f9b1c-4e6a-4c8d-a1f3-5b9e0c2d4f6a";
private const string STORED_FINGERPRINT = "AAAA";
private string tokenizerPath = string.Empty;
[SetUp]
public void CreateTokenizer()
{
this.tokenizerPath = Path.Combine(Path.GetTempPath(), $"ai-studio-tokenizer-{Guid.NewGuid():N}.json");
File.WriteAllText(this.tokenizerPath, """{ "version": "1.0", "model": { "type": "WordPiece" } }""");
}
[TearDown]
public void RemoveTokenizer() => File.Delete(this.tokenizerPath);
[Test]
public async Task AStoredFingerprintIsKeptWithoutReadingTheTokenizer()
{
var dataSource = new DataSourceLocalDirectory { Id = Guid.NewGuid().ToString(), Name = "Reports", Type = DataSourceType.LOCAL_DIRECTORY, EmbeddingId = EMBEDDING_ID };
var stored = new EmbeddingProvider(1, EMBEDDING_ID, "Test embeddings", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small"), TokenizerPath: this.tokenizerPath, TokenizerFingerprint: STORED_FINGERPRINT);
var storedFingerprint = await TokenizerFingerprint.KeepOrReadAsync(stored.TokenizerPath, stored.TokenizerFingerprint);
Assert.Multiple(() =>
{
Assert.That(storedFingerprint, Is.EqualTo(STORED_FINGERPRINT), "The file was read although its fingerprint was known.");
Assert.That(EmbeddingChangeImpact.AffectsStoredIndex(dataSource, stored, stored with { TokenizerFingerprint = storedFingerprint }), Is.False, "Saving a provider without touching its tokenizer must not index its data sources again.");
});
}
[Test]
public async Task WithoutATokenizerThereIsNoFingerprint() =>
Assert.That(await TokenizerFingerprint.KeepOrReadAsync(string.Empty, STORED_FINGERPRINT), Is.Empty, "A fingerprint left over from a removed tokenizer would make the signature claim a tokenizer.");
}