Fixed the tokenizer path in exported provider configurations (#1076)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-16 18:01:48 +02:00
1 parent 22065783c2
commit d333720f3f
12 files changed
+451 -23

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
using AIStudio.Tools.PluginSystem;
using Lua;
using SharedTools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that a text written as a Lua string literal reads back as the very same text.
/// </summary>
/// <remarks>
/// Profiles, chat templates, and document analysis policies are exported through these literals. Since
/// an export can be imported, it is read back on other computers as well: by Lua in a configuration
/// plugin, and by the snippet parser in an import. Long strings keep long texts readable, but Lua reads
/// them by rules of its own: it drops a line break right after the opening bracket, turns every kind of
/// line break into \n, and ends the string at the first closing bracket of its level. Each case is
/// therefore read both ways and compared with the original.
/// </remarks>
[TestFixture]
public sealed class LuaToolsTests
{
private static readonly string LONG_TEXT = string.Concat(Enumerable.Repeat("A text longer than what a quoted string takes. ", 3));
private static IEnumerable<TestCaseData> Texts()
{
foreach (var forceLongString in new[] { false, true })
{
var suffix = forceLongString ? ", forced long string" : string.Empty;
yield return new TestCaseData(string.Empty, forceLongString).SetArgDisplayNames($"An empty text{suffix}");
yield return new TestCaseData("Short, with \"quotes\" and C:\\Users\\me", forceLongString).SetArgDisplayNames($"Quotes and backslashes{suffix}");
yield return new TestCaseData(LONG_TEXT, forceLongString).SetArgDisplayNames($"A long text{suffix}");
yield return new TestCaseData("First line\nSecond line", forceLongString).SetArgDisplayNames($"A line break in between{suffix}");
yield return new TestCaseData("\nStarts with a line break", forceLongString).SetArgDisplayNames($"A line break at the start{suffix}");
yield return new TestCaseData("\n\nStarts with an empty line", forceLongString).SetArgDisplayNames($"An empty line at the start{suffix}");
yield return new TestCaseData("Windows line breaks\r\nin between\r\n", forceLongString).SetArgDisplayNames($"Windows line breaks{suffix}");
yield return new TestCaseData("An old Mac line break\rin between", forceLongString).SetArgDisplayNames($"A carriage return alone{suffix}");
yield return new TestCaseData("Holds ]] and ]===] in between", forceLongString).SetArgDisplayNames($"Closing brackets in between{suffix}");
yield return new TestCaseData("Ends like a closing bracket ]===", forceLongString).SetArgDisplayNames($"The start of a closing bracket at the end{suffix}");
yield return new TestCaseData("Holds ]===] and ends like the next level ]====", forceLongString).SetArgDisplayNames($"The start of the next level at the end{suffix}");
}
}
[TestCaseSource(nameof(Texts))]
public async Task LuaReadsTheLiteralAsTheOriginalText(string text, bool forceLongString)
{
var literal = LuaTools.ToLuaStringLiteral(text, forceLongString);
var state = LuaState.Create();
await state.DoStringAsync($"value = {literal}");
Assert.That(state.Environment["value"].Read<string>(), Is.EqualTo(text), literal);
}
[TestCaseSource(nameof(Texts))]
public void AnImportReadsTheLiteralAsTheOriginalText(string text, bool forceLongString)
{
var snippet = $$"""CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { ["Text"] = {{LuaTools.ToLuaStringLiteral(text, forceLongString)}} }""";
Assert.That(ConfigurationSnippetParser.TryParse(snippet, PluginConfigurationObjectType.PROFILE, out var table, out var issue), Is.True, issue);
Assert.That(table["Text"].Read<string>(), Is.EqualTo(text), snippet);
}
}
@@ -5,9 +5,12 @@ using AIStudio.Settings.DataModel;
using AIStudio.Tests.Tools.ToolCalling;
using AIStudio.Tools;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.Extensions.Logging.Abstractions;
using SharedTools;
using AuthMethod = AIStudio.Tools.ERIClient.DataModel.AuthMethod;
using Host = AIStudio.Provider.SelfHosted.Host;
@@ -42,18 +45,26 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
];
private string temporaryDirectory = string.Empty;
private string dataDirectory = string.Empty;
private string? previousDataDirectory;
[SetUp]
public void PrepareEncryptionAndFiles()
{
PluginFactory.InitializeEnterpriseEncryption(EnterpriseEncryption.GenerateSecret());
this.temporaryDirectory = Directory.CreateTempSubdirectory("snippet-import-").FullName;
// The data directory is static state of the whole application, too. An exported tokenizer is found below it:
this.previousDataDirectory = SettingsManager.DataDirectory;
this.dataDirectory = Path.Combine(this.temporaryDirectory, "data");
SettingsManager.DataDirectory = this.dataDirectory;
}
[TearDown]
public void RemoveEncryptionAndFiles()
{
RemoveEncryptionSecret();
SettingsManager.DataDirectory = this.previousDataDirectory;
Directory.Delete(this.temporaryDirectory, recursive: true);
}
@@ -288,12 +299,14 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
public void ATokenizerOnThisComputerIsKept(PluginConfigurationObjectType type)
{
var tokenizer = this.CreateFile("tokenizer.json");
var result = Import(ExportProvider(type, null, tokenizer).Snippet, type);
// The export keeps only the file name of the tokenizer and names the folder of the provider's copy itself:
var (original, snippet) = ExportProvider(type, null, Path.Combine(this.temporaryDirectory, "tokenizer.json"));
var copy = this.CreateTokenizerCopy(type, original.Id);
var result = Import(snippet, type);
Assert.Multiple(() =>
{
Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.EqualTo(tokenizer));
Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.EqualTo(copy), "Imported on the computer it came from, the export leads back to the copy of the provider.");
Assert.That(result.Hints, Is.Empty);
});
}
@@ -302,13 +315,41 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
public void ATokenizerWhichIsNotOnThisComputerIsLeftOut(PluginConfigurationObjectType type)
{
var tokenizer = Path.Combine(this.temporaryDirectory, "missing-tokenizer.json");
var result = Import(ExportProvider(type, null, tokenizer).Snippet, type);
var result = Import(ExportProvider(type, null, Path.Combine(this.temporaryDirectory, "missing-tokenizer.json")).Snippet, type);
Assert.Multiple(() =>
{
Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.Empty, "Saving the provider would fail on a tokenizer which cannot be copied.");
Assert.That(result.Hints, Has.Exactly(1).Contains(tokenizer));
Assert.That(result.Hints, Has.Exactly(1).Contains("missing-tokenizer.json"));
});
}
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
public void TheAbsoluteTokenizerPathOfAnOlderExportIsKept(PluginConfigurationObjectType type)
{
var tokenizer = this.CreateFile("tokenizer.json");
var result = Import(ExportProviderWithTokenizerPath(type, tokenizer), type);
Assert.Multiple(() =>
{
Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.EqualTo(tokenizer), "Versions before v26.10.1 exported the absolute path of the tokenizer.");
Assert.That(result.Hints, Is.Empty);
});
}
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
public void AnExportCannotNameAnotherFileOfTheDataDirectoryAsItsTokenizer(PluginConfigurationObjectType type)
{
Directory.CreateDirectory(this.dataDirectory);
File.WriteAllText(Path.Combine(this.dataDirectory, "settings.json"), "{}");
var result = Import(ExportProviderWithTokenizerPath(type, "tokenizers/../settings.json"), type);
Assert.Multiple(() =>
{
Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.Empty, "Only the folder of the tokenizers holds what an export may name.");
Assert.That(result.Hints, Has.Count.EqualTo(1));
});
}
@@ -456,6 +497,31 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
return path;
}
/// <summary>
/// Creates the tokenizer copy of a provider where the runtime stores it, below the data directory.
/// </summary>
private string CreateTokenizerCopy(PluginConfigurationObjectType type, string providerId)
{
var modelId = type is PluginConfigurationObjectType.LLM_PROVIDER ? TokenizerModelId.ForProviderId(providerId) : TokenizerModelId.ForEmbeddingProviderId(providerId);
var path = Path.Combine(this.dataDirectory, "tokenizers", modelId, "tokenizer.json");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "{}");
return path;
}
/// <summary>
/// What an export of a provider carries when its tokenizer path was written otherwise, e.g., by
/// an older version or by hand.
/// </summary>
private static string ExportProviderWithTokenizerPath(PluginConfigurationObjectType type, string tokenizerPath)
{
const string EMPTY_TOKENIZER_PATH = """["TokenizerPath"] = "",""";
var snippet = ExportProvider(type, null).Snippet;
Assert.That(snippet, Does.Contain(EMPTY_TOKENIZER_PATH));
return snippet.Replace(EMPTY_TOKENIZER_PATH, $"""["TokenizerPath"] = "{LuaTools.EscapeLuaString(tokenizerPath)}",""");
}
/// <summary>
/// What the app exports for one item of each type, along with the item.
/// </summary>
@@ -35,9 +35,11 @@ public sealed class ConfigurationSnippetParserTests
private const string QUOTED_TEXT = "He said \"hi\" to C:\\Users\\me, and 'bye'.";
/// <summary>
/// Every kind of line break, an indentation, and the closing brackets of long strings.
/// A line break at the start, an empty line, an indentation, and the closing brackets of long
/// strings, inside and at the end. Carriage returns are left to the output rules of the policy:
/// a text with one is exported as a quoted string, so it would never reach a long string here.
/// </summary>
private const string MULTILINE_TEXT = "First line\r\nSecond line\rThird line\n\tIndented, with ]] and ]==] and ]===] inside.";
private const string MULTILINE_TEXT = "\nFirst line\nSecond line\n\n\tIndented, with ]] and ]==] and ]===] inside, and ending like one: ]===";
private const string WINDOWS_PATH = @"C:\Users\me\AppData\Roaming\MindWork AI Studio\tokenizer.json";