Keep provider management working when tokenizer storage fails

This commit is contained in:
Thorsten Sommer 2026-09-06 11:20:54 +02:00
parent fe35630eff
commit 8950acb654
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
4 changed files with 46 additions and 39 deletions

View File

@ -3,7 +3,6 @@ using AIStudio.Dialogs;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.Services;
using AIStudio.Tools.Rust;
using Microsoft.AspNetCore.Components;
@ -144,8 +143,14 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
return;
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider, SecretStoreType.EMBEDDING_PROVIDER);
var deleteTokenizerResponse = await this.RustService.DeleteTokenizer(TokenizerModelId.ForEmbeddingProvider(provider));
if(deleteSecretResponse.Success && deleteTokenizerResponse.Success)
//
// Removing the tokenizer is best effort: it leaves an unused file behind when it fails,
// which is not worth bothering the user about while they are deleting the provider. The
// API key is different, though, because a leftover secret is a secret we promised to remove.
//
_ = await this.RustService.DeleteTokenizer(TokenizerModelId.ForEmbeddingProvider(provider));
if(deleteSecretResponse.Success)
{
this.SettingsManager.ConfigurationData.EmbeddingProviders.Remove(provider);
await this.SettingsManager.StoreSettings();
@ -154,7 +159,7 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
{
var issueDialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, string.Format(T("Couldn't delete the embedding provider '{0}'. The issue: {1}. We can ignore this issue and delete the embedding provider anyway. Do you want to ignore it and delete this embedding provider?"), provider.Name, BuildDeleteIssue(deleteSecretResponse, deleteTokenizerResponse)) },
{ x => x.Message, string.Format(T("Couldn't delete the embedding provider '{0}'. The issue: {1}. We can ignore this issue and delete the embedding provider anyway. Do you want to ignore it and delete this embedding provider?"), provider.Name, deleteSecretResponse.Issue) },
};
var issueDialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete Embedding Provider"), issueDialogParameters, DialogOptions.FULLSCREEN);
@ -171,18 +176,6 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private static string BuildDeleteIssue(DeleteSecretResponse deleteSecretResponse, TokenizerResponse deleteTokenizerResponse)
{
var issues = new List<string>();
if (!deleteSecretResponse.Success)
issues.Add(deleteSecretResponse.Issue);
if (!deleteTokenizerResponse.Success)
issues.Add(deleteTokenizerResponse.Message);
return string.Join(" | ", issues);
}
private async Task ExportEmbeddingProvider(EmbeddingProvider provider)
{
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)

View File

@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis;
using AIStudio.Dialogs;
using AIStudio.Settings;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
@ -134,8 +133,14 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
return;
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider, SecretStoreType.LLM_PROVIDER);
var deleteTokenizerResponse = await this.RustService.DeleteTokenizer(TokenizerModelId.ForProvider(provider));
if(deleteSecretResponse.Success && deleteTokenizerResponse.Success)
//
// Removing the tokenizer is best effort: it leaves an unused file behind when it fails,
// which is not worth bothering the user about while they are deleting the provider. The
// API key is different, though, because a leftover secret is a secret we promised to remove.
//
_ = await this.RustService.DeleteTokenizer(TokenizerModelId.ForProvider(provider));
if(deleteSecretResponse.Success)
{
this.SettingsManager.ConfigurationData.Providers.Remove(provider);
await this.SettingsManager.StoreSettings();
@ -144,7 +149,7 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
{
var issueDialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, string.Format(T("Couldn't delete the provider '{0}'. The issue: {1}. We can ignore this issue and delete the provider anyway. Do you want to ignore it and delete this provider?"), provider.InstanceName, BuildDeleteIssue(deleteSecretResponse, deleteTokenizerResponse)) },
{ x => x.Message, string.Format(T("Couldn't delete the provider '{0}'. The issue: {1}. We can ignore this issue and delete the provider anyway. Do you want to ignore it and delete this provider?"), provider.InstanceName, deleteSecretResponse.Issue) },
};
var issueDialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete LLM Provider"), issueDialogParameters, DialogOptions.FULLSCREEN);
@ -161,18 +166,6 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private static string BuildDeleteIssue(DeleteSecretResponse deleteSecretResponse, TokenizerResponse deleteTokenizerResponse)
{
var issues = new List<string>();
if (!deleteSecretResponse.Success)
issues.Add(deleteSecretResponse.Issue);
if (!deleteTokenizerResponse.Success)
issues.Add(deleteTokenizerResponse.Message);
return string.Join(" | ", issues);
}
private async Task ExportLLMProvider(AIStudio.Settings.Provider provider)
{
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)

View File

@ -327,10 +327,22 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
var tokenizerResponse = await this.StoreOrDeleteTokenizerAsync();
if (!tokenizerResponse.Success)
{
this.dataCustomTokenizerValidationIssue = tokenizerResponse.Message;
await this.form.Validate();
return;
//
// Storing a tokenizer the user has chosen must succeed: otherwise the provider would
// silently work without the tokenizer the user asked for. Removing a tokenizer the
// user has cleared is best effort, though. A failed cleanup leaves an unused file
// behind, which is no reason to refuse saving the provider itself.
//
if (!string.IsNullOrWhiteSpace(this.dataFilePath))
{
this.dataCustomTokenizerValidationIssue = tokenizerResponse.Message;
await this.form.Validate();
return;
}
this.Logger.LogWarning($"Failed to remove the tokenizer of provider '{this.DataInstanceName}'. The provider is stored anyway. The message was: {tokenizerResponse.Message}");
}
this.dataFilePath = tokenizerResponse.StoredPath;
// Use the data model to store the provider.
@ -465,10 +477,18 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
private Task<TokenizerResponse> StoreOrDeleteTokenizerAsync()
{
var tokenizerId = TokenizerModelId.ForProviderId(this.DataId);
if (string.IsNullOrWhiteSpace(this.dataFilePath))
return this.RustService.DeleteTokenizer(tokenizerId);
if (!string.IsNullOrWhiteSpace(this.dataFilePath))
return this.RustService.StoreTokenizer(tokenizerId, this.dataFilePath);
return this.RustService.StoreTokenizer(tokenizerId, this.dataFilePath);
//
// A provider which never had a tokenizer has nothing to clean up. Calling the runtime
// anyway could only fail here, and that failure would block saving a provider which has
// nothing to do with tokenizers at all.
//
if (string.IsNullOrWhiteSpace(this.DataTokenizerPath))
return Task.FromResult(new TokenizerResponse(true, 0, string.Empty));
return this.RustService.DeleteTokenizer(tokenizerId);
}
private void OnProviderChanged(LLMProviders selectedProvider)

View File

@ -38,7 +38,8 @@ public interface IProvider
/// The tokenizer path associated with this provider configuration.
/// </summary>
public string TokenizerPath { get; }
/// <summary>
/// Whether this provider instance can load available models from the backend/API.
/// This capability may differ by provider type, host, or modality.
/// </summary>