2026-09-13 14:17:25 +02:00
using System.Globalization ;
2026-03-12 12:11:54 +01:00
using System.Text ;
using System.Text.Json ;
2025-04-27 16:13:15 +02:00
using AIStudio.Components ;
2026-09-13 14:17:25 +02:00
using AIStudio.Models ;
2024-04-19 21:25:44 +02:00
using AIStudio.Provider ;
2025-04-11 14:31:10 +02:00
using AIStudio.Provider.HuggingFace ;
2026-09-09 18:43:37 +02:00
using AIStudio.Tools.Rust ;
2026-07-05 12:42:55 +02:00
using AIStudio.Settings ;
2026-09-10 19:18:40 +02:00
using AIStudio.Settings.DataModel ;
2025-02-15 15:41:12 +01:00
using AIStudio.Tools.Services ;
2024-12-03 15:24:40 +01:00
using AIStudio.Tools.Validation ;
2024-04-19 21:25:44 +02:00
using Microsoft.AspNetCore.Components ;
2026-09-09 18:43:37 +02:00
using Microsoft.AspNetCore.Components.Web ;
2024-04-19 21:25:44 +02:00
2024-07-16 10:28:13 +02:00
using Host = AIStudio . Provider . SelfHosted . Host ;
2024-09-01 20:10:03 +02:00
namespace AIStudio.Dialogs ;
2024-04-19 21:25:44 +02:00
2024-05-04 10:55:00 +02:00
/// <summary>
/// The provider settings dialog.
/// </summary>
2025-04-27 16:13:15 +02:00
public partial class ProviderDialog : MSGComponentBase , ISecretId
2024-04-19 21:25:44 +02:00
{
2026-07-05 12:42:55 +02:00
private enum ReasoningOverrideMode
{
AUTOMATIC ,
NO_REASONING ,
CAN_BE_ENABLED ,
ON_BY_DEFAULT ,
ALWAYS_ON
}
2024-04-19 21:25:44 +02:00
[CascadingParameter]
2025-03-12 19:12:56 +01:00
private IMudDialogInstance MudDialog { get ; set ; } = null !;
2024-05-19 20:28:25 +02:00
/// <summary>
/// The provider's number in the list.
/// </summary>
[Parameter]
public uint DataNum { get ; set ; }
2024-04-19 23:27:38 +02:00
2024-05-04 10:55:00 +02:00
/// <summary>
/// The provider's ID.
/// </summary>
2024-04-19 23:27:38 +02:00
[Parameter]
2024-04-20 17:06:50 +02:00
public string DataId { get ; set ; } = Guid . NewGuid (). ToString ();
2024-05-04 10:55:00 +02:00
/// <summary>
/// The user chosen instance name.
/// </summary>
2024-04-20 17:06:50 +02:00
[Parameter]
public string DataInstanceName { get ; set ; } = string . Empty ;
2024-07-03 20:31:04 +02:00
/// <summary>
/// The chosen hostname for self-hosted providers.
/// </summary>
[Parameter]
public string DataHostname { get ; set ; } = string . Empty ;
2024-07-16 10:28:13 +02:00
/// <summary>
2024-12-03 15:24:40 +01:00
/// The host to use, e.g., llama.cpp.
2024-07-16 10:28:13 +02:00
/// </summary>
[Parameter]
public Host DataHost { get ; set ; } = Host . NONE ;
2025-04-11 14:31:10 +02:00
/// <summary>
/// The HFInstanceProvider to use, e.g., CEREBRAS.
/// </summary>
[Parameter]
2025-04-20 15:24:43 +02:00
public HFInferenceProvider HFInferenceProviderId { get ; set ; } = HFInferenceProvider . NONE ;
2025-04-11 14:31:10 +02:00
2024-07-03 20:31:04 +02:00
/// <summary>
/// Is this provider self-hosted?
/// </summary>
[Parameter]
public bool IsSelfHosted { get ; set ; }
2024-05-04 10:55:00 +02:00
/// <summary>
/// The provider to use.
/// </summary>
2024-04-20 17:06:50 +02:00
[Parameter]
2024-09-13 21:50:00 +02:00
public LLMProviders DataLLMProvider { get ; set ; } = LLMProviders . NONE ;
2026-08-25 12:46:10 +02:00
/// <summary>
/// The validated custom icon supplied by a configuration plugin.
/// </summary>
[Parameter]
public string DataCustomIconDataUrl { get ; set ; } = string . Empty ;
2024-05-04 10:55:00 +02:00
2024-05-19 16:12:07 +02:00
/// <summary>
/// The LLM model to use, e.g., GPT-4o.
/// </summary>
[Parameter]
public Model DataModel { get ; set ; }
2024-05-04 10:55:00 +02:00
/// <summary>
/// Should the dialog be in editing mode?
/// </summary>
2024-04-20 17:06:50 +02:00
[Parameter]
public bool IsEditing { get ; init ; }
2026-08-15 11:20:36 +02:00
/// <summary>
/// Whether this provider is managed by an enterprise configuration plugin. When true, every
/// field except the API key is locked, matching Settings.Provider.IsEnterpriseConfiguration.
/// </summary>
[Parameter]
public bool IsEnterpriseConfiguration { get ; set ; }
2024-04-20 17:06:50 +02:00
2025-11-13 18:13:16 +01:00
[Parameter]
public string AdditionalJsonApiParameters { get ; set ; } = string . Empty ;
2026-07-05 12:42:55 +02:00
2026-09-09 18:43:37 +02:00
[Parameter]
public string DataTokenizerPath { get ; set ; } = string . Empty ;
2026-07-05 12:42:55 +02:00
[Parameter]
public ProviderCapabilityOverrides ? DataCapabilityOverrides { get ; set ; }
2025-11-13 18:13:16 +01:00
2024-09-01 20:10:03 +02:00
[Inject]
private RustService RustService { get ; init ; } = null !;
2024-04-19 21:25:44 +02:00
2026-01-18 17:15:18 +01:00
[Inject]
private ILogger < ProviderDialog > Logger { get ; init ; } = null !;
2024-07-16 10:28:13 +02:00
private static readonly Dictionary < string , object? > SPELLCHECK_ATTRIBUTES = new ();
2026-07-05 12:42:55 +02:00
private static readonly IReadOnlyList < Capability > SWITCH_CAPABILITY_OVERRIDES =
[
Capability.AUDIO_INPUT,
2026-09-04 15:48:07 +02:00
Capability.FUNCTION_CALLING,
2026-07-05 12:42:55 +02:00
Capability.MULTIPLE_IMAGE_INPUT,
Capability.SPEECH_INPUT,
Capability.VIDEO_INPUT
] ;
private static readonly IReadOnlyList < ReasoningOverrideMode > REASONING_OVERRIDE_MODES =
[
ReasoningOverrideMode.AUTOMATIC,
ReasoningOverrideMode.NO_REASONING,
ReasoningOverrideMode.CAN_BE_ENABLED,
ReasoningOverrideMode.ON_BY_DEFAULT,
ReasoningOverrideMode.ALWAYS_ON
] ;
2026-09-09 18:43:37 +02:00
2024-05-04 10:55:00 +02:00
/// <summary>
/// The list of used instance names. We need this to check for uniqueness.
/// </summary>
2024-05-19 16:12:50 +02:00
private List < string > UsedInstanceNames { get ; set ; } = [];
2024-04-20 17:06:50 +02:00
2024-04-19 21:25:44 +02:00
private bool dataIsValid ;
private string [] dataIssues = [];
2024-04-20 17:06:50 +02:00
private string dataAPIKey = string . Empty ;
2026-08-15 11:20:36 +02:00
private bool dataHadStoredAPIKeyOnLoad ;
2024-07-25 15:29:44 +02:00
private string dataManuallyModel = string . Empty ;
2024-04-20 17:06:50 +02:00
private string dataAPIKeyStorageIssue = string . Empty ;
private string dataEditingPreviousInstanceName = string . Empty ;
2026-01-18 17:15:18 +01:00
private string dataLoadingModelsIssue = string . Empty ;
2026-09-09 18:43:37 +02:00
private string dataFilePath = string . Empty ;
private string dataCustomTokenizerValidationIssue = string . Empty ;
private Task dataTokenizerValidationTask = Task . CompletedTask ;
private bool dataStoreWasAttempted ;
private bool isTokenizerFileDialogOpen ;
private int dataTokenizerValidationRevision ;
2026-06-11 15:46:17 +02:00
private bool usesLegacySystemModelFallback ;
2025-11-13 18:13:16 +01:00
private bool showExpertSettings ;
2026-07-05 12:42:55 +02:00
private ProviderCapabilityOverrides capabilityOverrides = new ();
2026-09-13 14:17:25 +02:00
/// <summary>
/// The culture the numbers of this dialog are written in.
/// </summary>
/// <remarks>
/// AI Studio's language is a setting of its own and does not move the thread's culture along
/// with it. Without this, a German who chose German would read a context window of 131,072
/// tokens as a number a thousand times smaller.
/// </remarks>
private CultureInfo currentCulture = CultureInfo . InvariantCulture ;
2024-04-19 21:25:44 +02:00
2024-05-04 10:55:00 +02:00
// We get the form reference from Blazor code to validate it manually:
2024-04-19 21:25:44 +02:00
private MudForm form = null !;
2024-05-19 16:12:07 +02:00
private readonly List < Model > availableModels = new ();
2024-09-01 20:10:03 +02:00
private readonly Encryption encryption = Program . ENCRYPTION ;
2024-12-03 15:24:40 +01:00
private readonly ProviderValidation providerValidation ;
public ProviderDialog ()
{
this . providerValidation = new ()
{
GetProvider = () => this . DataLLMProvider ,
GetAPIKeyStorageIssue = () => this . dataAPIKeyStorageIssue ,
GetPreviousInstanceName = () => this . dataEditingPreviousInstanceName ,
GetUsedInstanceNames = () => this . UsedInstanceNames ,
GetHost = () => this . DataHost ,
2026-01-18 17:15:18 +01:00
IsModelProvidedManually = () => this . DataLLMProvider . IsLLMModelProvidedManually (),
2026-09-09 18:43:37 +02:00
GetCustomTokenizerValidationIssue = () => this . dataCustomTokenizerValidationIssue ,
2026-06-11 15:46:17 +02:00
IsModelSelectionHidden = () => this . IsLLMModelSelectionHidden ,
2024-12-03 15:24:40 +01:00
};
}
2024-09-01 20:10:03 +02:00
2025-01-05 15:11:15 +01:00
private AIStudio . Settings . Provider CreateProviderSettings ()
2024-07-16 10:28:13 +02:00
{
2024-11-14 06:39:35 +01:00
var cleanedHostname = this . DataHostname . Trim ();
2026-01-18 17:15:18 +01:00
2024-11-14 06:39:35 +01:00
return new ()
{
Num = this . DataNum ,
Id = this . DataId ,
InstanceName = this . DataInstanceName ,
UsedLLMProvider = this . DataLLMProvider ,
2026-07-06 15:19:10 +02:00
Model = this . GetSelectedModel (),
2024-11-14 06:39:35 +01:00
IsSelfHosted = this . DataLLMProvider is LLMProviders . SELF_HOSTED ,
2026-08-15 11:20:36 +02:00
IsEnterpriseConfiguration = this . IsEnterpriseConfiguration ,
2024-11-14 06:39:35 +01:00
Hostname = cleanedHostname . EndsWith ( '/' ) ? cleanedHostname [..^ 1 ] : cleanedHostname ,
Host = this . DataHost ,
2025-04-20 15:24:43 +02:00
HFInferenceProvider = this . HFInferenceProviderId ,
2025-11-13 18:13:16 +01:00
AdditionalJsonApiParameters = this . AdditionalJsonApiParameters ,
2026-09-09 18:43:37 +02:00
TokenizerPath = this . dataFilePath ,
2026-07-05 12:42:55 +02:00
CapabilityOverrides = this . capabilityOverrides . HasOverrides ? this . capabilityOverrides : null ,
2026-08-25 12:46:10 +02:00
CustomIconDataUrl = this . DataCustomIconDataUrl ,
2024-11-14 06:39:35 +01:00
};
}
2024-04-19 21:25:44 +02:00
2026-07-06 15:19:10 +02:00
private Model GetSelectedModel ()
{
if ( this . IsLLMModelSelectionHidden )
return Model . SYSTEM_MODEL ;
if ( this . DataLLMProvider . IsLLMModelProvidedManually ())
return new Model ( this . dataManuallyModel , null );
return this . DataModel ;
}
2024-04-20 17:06:50 +02:00
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync ()
{
2026-01-11 16:02:28 +01:00
// Call the base initialization first so that the I18N is ready:
await base . OnInitializedAsync ();
2026-09-13 14:17:25 +02:00
// The numbers of the expert settings are written the way the chosen language writes them:
var activeLanguagePlugin = await this . SettingsManager . GetActiveLanguagePlugin ();
this . currentCulture = CommonTools . DeriveActiveCultureOrInvariant ( activeLanguagePlugin . IETFTag );
2024-06-01 20:00:17 +02:00
// Configure the spellchecking for the instance name input:
2024-07-16 10:28:13 +02:00
this . SettingsManager . InjectSpellchecking ( SPELLCHECK_ATTRIBUTES );
2024-06-01 20:00:17 +02:00
2024-05-04 10:55:00 +02:00
// Load the used instance names:
2026-08-12 20:44:48 +02:00
this . UsedInstanceNames = this . SettingsManager . GetAllProviders (). Select ( x => x . InstanceName . ToLowerInvariant ()). ToList ();
2025-11-13 18:13:16 +01:00
2026-07-05 12:42:55 +02:00
this . capabilityOverrides = this . DataCapabilityOverrides ?? new ();
2026-09-10 19:18:40 +02:00
this . showExpertSettings = ! string . IsNullOrWhiteSpace ( this . AdditionalJsonApiParameters )
|| this . capabilityOverrides . HasOverrides
|| ( this . ShowTokenizerSettings && ! string . IsNullOrWhiteSpace ( this . DataTokenizerPath ));
2024-04-20 17:06:50 +02:00
2024-05-04 10:55:00 +02:00
// When editing, we need to load the data:
2024-04-20 17:06:50 +02:00
if ( this . IsEditing )
{
this . dataEditingPreviousInstanceName = this . DataInstanceName . ToLowerInvariant ();
2026-09-09 18:43:37 +02:00
this . dataFilePath = this . DataTokenizerPath ;
2024-07-16 10:28:13 +02:00
2026-08-30 16:45:14 +02:00
// When using Fireworks, we must copy the model name:
2025-08-31 14:27:35 +02:00
if ( this . DataLLMProvider . IsLLMModelProvidedManually ())
2024-09-08 21:01:51 +02:00
this . dataManuallyModel = this . DataModel . Id ;
2024-07-16 10:28:13 +02:00
//
// We cannot load the API key for self-hosted providers:
//
2025-08-10 16:26:25 +02:00
if ( this . DataLLMProvider is LLMProviders . SELF_HOSTED && this . DataHost is not Host . OLLAMA && this . DataHost is not Host . VLLM )
2024-07-16 10:28:13 +02:00
{
await this . ReloadModels ();
await base . OnInitializedAsync ();
return ;
}
2024-05-04 10:55:00 +02:00
// Load the API key:
2026-01-11 16:02:28 +01:00
var requestedSecret = await this . RustService . GetAPIKey ( this , SecretStoreType . LLM_PROVIDER , isTrying : this . DataLLMProvider is LLMProviders . SELF_HOSTED );
2024-12-03 15:24:40 +01:00
if ( requestedSecret . Success )
2026-08-15 11:20:36 +02:00
{
2024-09-01 20:10:03 +02:00
this . dataAPIKey = await requestedSecret . Secret . Decrypt ( this . encryption );
2026-08-15 11:20:36 +02:00
this . dataHadStoredAPIKeyOnLoad = ! string . IsNullOrWhiteSpace ( this . dataAPIKey );
}
2024-04-20 17:06:50 +02:00
else
{
2024-10-07 13:26:25 +02:00
this . dataAPIKey = string . Empty ;
2026-08-15 11:20:36 +02:00
// For an enterprise-managed provider, having no key yet is the expected first-run
// state, not a storage failure -- the user is just about to set their own key:
if ( this . DataLLMProvider is not LLMProviders . SELF_HOSTED && ! this . IsEnterpriseConfiguration )
2024-10-07 13:26:25 +02:00
{
2025-04-27 16:13:15 +02:00
this . dataAPIKeyStorageIssue = string . Format ( T ( "Failed to load the API key from the operating system. The message was: {0}. You might ignore this message and provide the API key again." ), requestedSecret . Issue );
2024-10-07 13:26:25 +02:00
await this . form . Validate ();
}
2024-04-20 17:06:50 +02:00
}
2024-12-03 15:24:40 +01:00
await this . ReloadModels ();
2024-04-20 17:06:50 +02:00
}
}
protected override async Task OnAfterRenderAsync ( bool firstRender )
{
2024-05-04 10:55:00 +02:00
// Reset the validation when not editing and on the first render.
// We don't want to show validation errors when the user opens the dialog.
2024-04-20 17:06:50 +02:00
if (! this . IsEditing && firstRender )
this . form . ResetValidation ();
await base . OnAfterRenderAsync ( firstRender );
}
#endregion
2024-12-03 15:24:40 +01:00
#region Implementation of ISecretId
2026-08-15 11:20:36 +02:00
// Must mirror Settings.Provider.SecretId exactly: when editing an enterprise-managed
// provider, the key has to be stored under the same "ENT::"-prefixed keyring row that the
// app reads from at runtime (see BaseProvider.SecretId). Otherwise, a key entered here would
// silently end up in the wrong keyring row and never be found again.
public string SecretId => this . IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.DataLLMProvider.ToSecretId()}" : this . DataLLMProvider . ToSecretId ();
2024-12-03 15:24:40 +01:00
public string SecretName => this . DataInstanceName ;
#endregion
2024-04-20 17:06:50 +02:00
private async Task Store ()
2024-04-19 21:25:44 +02:00
{
2026-09-09 18:43:37 +02:00
this . dataStoreWasAttempted = true ;
await this . dataTokenizerValidationTask ;
2024-04-19 21:25:44 +02:00
await this . form . Validate ();
2024-04-20 17:06:50 +02:00
if (! string . IsNullOrWhiteSpace ( this . dataAPIKeyStorageIssue ))
this . dataAPIKeyStorageIssue = string . Empty ;
2026-01-18 17:15:18 +01:00
// Manually validate the model selection (needed when no models are loaded
// and the MudSelect is not rendered):
var modelValidationError = this . providerValidation . ValidatingModel ( this . DataModel );
if (! string . IsNullOrWhiteSpace ( modelValidationError ))
{
this . dataIssues = [.. this . dataIssues , modelValidationError ];
this . dataIsValid = false ;
}
2024-05-04 10:55:00 +02:00
// When the data is not valid, we don't store it:
2024-04-19 21:25:44 +02:00
if (! this . dataIsValid )
return ;
2026-09-09 18:43:37 +02:00
var tokenizerResponse = await this . StoreOrDeleteTokenizerAsync ();
if (! tokenizerResponse . Success )
{
//
// 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 ;
2024-04-19 21:25:44 +02:00
2024-05-04 10:55:00 +02:00
// Use the data model to store the provider.
// We just return this data to the parent component:
2024-07-16 10:28:13 +02:00
var addedProviderSettings = this . CreateProviderSettings ();
2024-10-07 13:26:25 +02:00
if (! string . IsNullOrWhiteSpace ( this . dataAPIKey ))
2024-04-19 21:25:44 +02:00
{
2024-07-16 10:28:13 +02:00
// Store the API key in the OS secure storage:
2026-01-11 16:02:28 +01:00
var storeResponse = await this . RustService . SetAPIKey ( this , this . dataAPIKey , SecretStoreType . LLM_PROVIDER );
2024-07-16 10:28:13 +02:00
if (! storeResponse . Success )
{
2025-04-27 16:13:15 +02:00
this . dataAPIKeyStorageIssue = string . Format ( T ( "Failed to store the API key in the operating system. The message was: {0}. Please try again." ), storeResponse . Issue );
2024-07-16 10:28:13 +02:00
await this . form . Validate ();
return ;
}
2026-08-15 11:20:36 +02:00
this . dataHadStoredAPIKeyOnLoad = true ;
}
else if ( this . dataHadStoredAPIKeyOnLoad )
{
// The user cleared a previously stored key. Without this, the old key would simply
// stay in the OS keyring untouched and keep being used:
var deleteResponse = await this . RustService . DeleteAPIKey ( this , SecretStoreType . LLM_PROVIDER );
if (! deleteResponse . Success )
{
this . dataAPIKeyStorageIssue = string . Format ( T ( "Failed to remove the API key from the operating system. The message was: {0}. Please try again." ), deleteResponse . Issue );
await this . form . Validate ();
return ;
}
this . dataHadStoredAPIKeyOnLoad = false ;
2024-04-20 17:06:50 +02:00
}
2024-07-16 10:28:13 +02:00
this . MudDialog . Close ( DialogResult . Ok ( addedProviderSettings ));
2024-04-19 21:25:44 +02:00
}
2024-07-25 15:29:44 +02:00
private string? ValidateManuallyModel ( string manuallyModel )
{
2025-08-31 14:27:35 +02:00
if ( this . DataLLMProvider . IsLLMModelProvidedManually () && string . IsNullOrWhiteSpace ( manuallyModel ))
2025-04-27 16:13:15 +02:00
return T ( "Please enter a model name." );
2024-07-25 15:29:44 +02:00
return null ;
}
2024-05-19 16:12:07 +02:00
2024-07-03 20:31:04 +02:00
private void Cancel () => this . MudDialog . Cancel ();
2026-01-11 16:02:28 +01:00
private async Task OnAPIKeyChanged ( string apiKey )
{
this . dataAPIKey = apiKey ;
if (! string . IsNullOrWhiteSpace ( this . dataAPIKeyStorageIssue ))
{
this . dataAPIKeyStorageIssue = string . Empty ;
await this . form . Validate ();
}
}
2026-01-18 17:15:18 +01:00
2026-09-09 18:43:37 +02:00
private async Task OpenTokenizerFileDialog ()
{
if ( this . isTokenizerFileDialogOpen )
return ;
this . isTokenizerFileDialogOpen = true ;
try
{
var response = await this . RustService . SelectFile ( T ( "Choose a custom tokenizer here" ), [ FileTypes . JSON ], string . IsNullOrWhiteSpace ( this . dataFilePath ) ? null : this . dataFilePath );
if (! response . UserCancelled )
await this . OnDataFilePathChanged ( response . SelectedFilePath );
}
finally
{
this . isTokenizerFileDialogOpen = false ;
}
}
private Task ClearPathTokenizer ( MouseEventArgs _ )
{
return this . OnDataFilePathChanged ( string . Empty );
}
2026-09-10 19:18:40 +02:00
/// <summary>
/// Takes the first dropped path which can serve as a tokenizer.
/// </summary>
/// <remarks>
/// A provider carries exactly one tokenizer, so a multi-selection cannot be honored as a whole.
/// Everything which is not a readable JSON file is skipped rather than handed to the runtime:
/// the validation would reject it anyway, and saying so right away names the actual mistake.
/// </remarks>
/// <param name="paths">The dropped paths.</param>
private async Task OnTokenizerPathsDropped ( List < string > paths )
{
foreach ( var path in paths )
{
if (! File . Exists ( path ) || ! FileTypes . IsAllowedPath ( path , FileTypes . JSON ))
continue ;
await this . OnDataFilePathChanged ( path );
return ;
}
this . Logger . LogWarning ( "None of the {Count} dropped path(s) could be used as a tokenizer." , paths . Count );
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . Warning , T ( "Please drop a tokenizer file in the JSON format." )));
}
2026-09-09 18:43:37 +02:00
private async Task OnDataFilePathChanged ( string filePath )
{
this . dataFilePath = filePath ;
var validationRevision = ++ this . dataTokenizerValidationRevision ;
this . dataTokenizerValidationTask = this . ValidateCustomTokenizer ( filePath , validationRevision );
await this . dataTokenizerValidationTask ;
if ( validationRevision != this . dataTokenizerValidationRevision )
return ;
if ( this . dataStoreWasAttempted )
await this . form . Validate ();
else
this . form . ResetValidation ();
}
private async Task ValidateCustomTokenizer ( string filePath , int validationRevision )
{
if ( string . IsNullOrWhiteSpace ( filePath ))
{
if ( validationRevision == this . dataTokenizerValidationRevision )
this . dataCustomTokenizerValidationIssue = string . Empty ;
return ;
}
try
{
var response = await this . RustService . ValidateTokenizer ( filePath );
if ( validationRevision != this . dataTokenizerValidationRevision )
return ;
if ( response . Success )
this . dataCustomTokenizerValidationIssue = string . Empty ;
else
this . dataCustomTokenizerValidationIssue = T ( "Invalid tokenizer: " ) + response . Message ;
}
catch ( Exception e )
{
if ( validationRevision != this . dataTokenizerValidationRevision )
return ;
this . Logger . LogError ( e , "Failed to validate custom tokenizer." );
this . dataCustomTokenizerValidationIssue = T ( "Failed to validate the selected tokenizer. Please try again." );
}
}
/// <summary>
/// Stores a new tokenizer or deletes the existing one, based on the specified tokenizer path.
/// If the path is null or empty, any existing tokenizer is removed.
/// Otherwise, the tokenizer is stored at the specified path.
/// </summary>
private Task < TokenizerResponse > StoreOrDeleteTokenizerAsync ()
{
var tokenizerId = TokenizerModelId . ForProviderId ( this . DataId );
if (! string . IsNullOrWhiteSpace ( 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 );
}
2026-07-06 15:19:10 +02:00
private void OnProviderChanged ( LLMProviders selectedProvider )
{
this . DataLLMProvider = selectedProvider ;
this . DataModel = default ;
this . dataManuallyModel = string . Empty ;
this . capabilityOverrides = new ();
this . availableModels . Clear ();
this . dataLoadingModelsIssue = string . Empty ;
this . usesLegacySystemModelFallback = false ;
}
2026-08-30 16:45:14 +02:00
/// <summary>
/// Resets the model selection when the user picks another Hugging Face inference provider.
/// </summary>
/// <remarks>
/// Which models are on offer depends on the inference provider, so the models loaded for the
/// previous one say nothing about the new one. Keeping them would let the user pick a model
/// their provider does not serve, which the router answers with an error.
/// </remarks>
/// <param name="selectedInferenceProvider">The inference provider the user chose.</param>
private void OnHFInferenceProviderChanged ( HFInferenceProvider selectedInferenceProvider )
{
this . HFInferenceProviderId = selectedInferenceProvider ;
this . DataModel = default ;
this . capabilityOverrides = new ();
this . availableModels . Clear ();
this . dataLoadingModelsIssue = string . Empty ;
}
2026-01-18 17:15:18 +01:00
private void OnHostChanged ( Host selectedHost )
{
// When the host changes, reset the model selection state:
this . DataHost = selectedHost ;
this . DataModel = default ;
this . dataManuallyModel = string . Empty ;
2026-07-05 12:42:55 +02:00
this . capabilityOverrides = new ();
2026-01-18 17:15:18 +01:00
this . availableModels . Clear ();
this . dataLoadingModelsIssue = string . Empty ;
2026-06-11 15:46:17 +02:00
this . usesLegacySystemModelFallback = false ;
2026-01-18 17:15:18 +01:00
}
2026-07-05 12:42:55 +02:00
private Task OnModelChanged ( Model selectedModel )
{
this . DataModel = selectedModel ;
this . capabilityOverrides = new ();
return Task . CompletedTask ;
}
2024-05-19 19:42:15 +02:00
2024-05-19 16:12:07 +02:00
private async Task ReloadModels ()
{
2026-01-18 17:15:18 +01:00
this . dataLoadingModelsIssue = string . Empty ;
2024-07-16 10:28:13 +02:00
var currentProviderSettings = this . CreateProviderSettings ();
2025-09-03 21:25:17 +02:00
var provider = currentProviderSettings . CreateProvider ();
2026-01-18 17:15:18 +01:00
if ( provider is NoProvider )
2024-05-19 16:12:07 +02:00
return ;
2026-01-18 17:15:18 +01:00
try
{
2026-04-14 13:39:11 +02:00
var result = await provider . GetTextModels ( this . dataAPIKey );
if (! result . Success )
this . dataLoadingModelsIssue = result . FailureReason . ToUserMessage ( provider . InstanceName );
2026-01-18 17:15:18 +01:00
// Order descending by ID means that the newest models probably come first:
2026-04-14 13:39:11 +02:00
var orderedModels = result . Models . OrderByDescending ( n => n . Id );
2026-01-18 17:15:18 +01:00
this . availableModels . Clear ();
this . availableModels . AddRange ( orderedModels );
2026-06-11 15:46:17 +02:00
this . UpdateModelSelectionAfterLoading ();
2026-01-18 17:15:18 +01:00
}
catch ( Exception e )
{
this . Logger . LogError ( $"Failed to load models from provider '{this.DataLLMProvider}' (host={this.DataHost}, hostname='{this.DataHostname}'): {e.Message}" );
this . dataLoadingModelsIssue = T ( "We are currently unable to communicate with the provider to load models. Please try again later." );
}
2024-05-19 16:12:07 +02:00
}
2024-07-03 20:31:04 +02:00
2024-10-07 13:26:25 +02:00
private string APIKeyText => this . DataLLMProvider switch
{
2025-04-27 16:13:15 +02:00
LLMProviders . SELF_HOSTED => T ( "(Optional) API Key" ),
_ => T ( "API Key" ),
2024-10-07 13:26:25 +02:00
};
2026-06-11 15:46:17 +02:00
private bool IsLLMModelSelectionHidden => this . DataLLMProvider . IsLLMModelSelectionHidden ( this . DataHost ) ||
this . DataLLMProvider is LLMProviders . SELF_HOSTED &&
this . DataHost is Host . LLAMA_CPP &&
this . usesLegacySystemModelFallback ;
2026-08-30 16:45:14 +02:00
/// <summary>
/// The catalog of the provider, where the user can read up on the models before choosing one.
/// </summary>
private string ModelsOverviewURL => this . DataLLMProvider . GetModelsOverviewURL ( this . HFInferenceProviderId );
2026-09-10 19:18:40 +02:00
/// <summary>
/// Whether the custom tokenizer is offered at all. It is an expert setting which only makes
/// sense while the RAG preview is enabled.
/// </summary>
private bool ShowTokenizerSettings => this . DataLLMProvider != LLMProviders . NONE && PreviewFeatures . PRE_RAG_2024 . IsEnabled ( this . SettingsManager );
2026-06-11 15:46:17 +02:00
private void UpdateModelSelectionAfterLoading ()
{
if ( this . DataLLMProvider is not LLMProviders . SELF_HOSTED || this . DataHost is not Host . LLAMA_CPP )
return ;
this . usesLegacySystemModelFallback = this . availableModels . Count is 1 && this . availableModels [ 0 ]. IsSystemModel ;
if ( this . usesLegacySystemModelFallback )
{
this . DataModel = Model . SYSTEM_MODEL ;
return ;
}
var availableModel = this . availableModels . FirstOrDefault ( model =>
string . Equals ( model . Id , this . DataModel . Id , StringComparison . OrdinalIgnoreCase ));
if ( availableModel != default )
{
this . DataModel = availableModel ;
return ;
}
this . DataModel = this . availableModels . Count is 1 ? this . availableModels [ 0 ] : default ;
}
2025-11-13 18:13:16 +01:00
private void ToggleExpertSettings () => this . showExpertSettings = ! this . showExpertSettings ;
2026-07-05 12:42:55 +02:00
private void SetCapabilityOverride ( Capability capability , bool value )
{
this . capabilityOverrides = this . capabilityOverrides . SetOverride ( capability , value );
}
private Task OnCapabilitySwitchChanged ( Capability capability , bool value )
{
this . SetCapabilityOverride ( capability , value );
return Task . CompletedTask ;
}
private void ResetCapabilityOverride ( Capability capability ) =>
this . capabilityOverrides = this . capabilityOverrides . SetOverride ( capability , null );
private ReasoningOverrideMode GetReasoningOverrideMode ()
{
var alwaysReasoning = this . capabilityOverrides . GetOverride ( Capability . ALWAYS_REASONING );
var optionalReasoning = this . capabilityOverrides . GetOverride ( Capability . OPTIONAL_REASONING );
var reasoningByDefault = this . capabilityOverrides . GetOverride ( Capability . REASONING_BY_DEFAULT );
if ( alwaysReasoning is null && optionalReasoning is null && reasoningByDefault is null )
return ReasoningOverrideMode . AUTOMATIC ;
2026-09-13 14:17:25 +02:00
return ModeOf ( this . GetCurrentModelProfile (). Reasoning );
2026-07-05 12:42:55 +02:00
}
2026-09-13 14:17:25 +02:00
private ReasoningOverrideMode GetAutomaticReasoningOverrideMode () => ModeOf ( this . GetAutomaticModelProfile (). Reasoning );
2026-07-05 12:42:55 +02:00
2026-09-13 14:17:25 +02:00
/// <summary>
/// Which of the choices in this dialog a reasoning state is.
/// </summary>
/// <remarks>
/// The five entries of the list were always this one answer, only written as three flags and
/// read back by asking for them in the right order. Now they are the same four words plus
/// "automatic", which is the absence of a statement rather than a state a model can be in.
/// </remarks>
/// <param name="reasoning">How the model reasons.</param>
/// <returns>The choice standing for it.</returns>
private static ReasoningOverrideMode ModeOf ( ReasoningSupport reasoning ) => reasoning switch
{
ReasoningSupport . ALWAYS => ReasoningOverrideMode . ALWAYS_ON ,
ReasoningSupport . ON_BY_DEFAULT => ReasoningOverrideMode . ON_BY_DEFAULT ,
ReasoningSupport . OPTIONAL => ReasoningOverrideMode . CAN_BE_ENABLED ,
2026-07-05 12:42:55 +02:00
2026-09-13 14:17:25 +02:00
_ => ReasoningOverrideMode . NO_REASONING ,
};
2026-07-05 12:42:55 +02:00
private void SetReasoningOverrideMode ( ReasoningOverrideMode mode )
{
this . capabilityOverrides = mode switch
{
ReasoningOverrideMode . AUTOMATIC => this . capabilityOverrides
. SetOverride ( Capability . ALWAYS_REASONING , null )
. SetOverride ( Capability . OPTIONAL_REASONING , null )
. SetOverride ( Capability . REASONING_BY_DEFAULT , null ),
ReasoningOverrideMode . NO_REASONING => this . capabilityOverrides
. SetOverride ( Capability . ALWAYS_REASONING , false )
. SetOverride ( Capability . OPTIONAL_REASONING , false )
. SetOverride ( Capability . REASONING_BY_DEFAULT , false ),
ReasoningOverrideMode . CAN_BE_ENABLED => this . capabilityOverrides
. SetOverride ( Capability . ALWAYS_REASONING , false )
. SetOverride ( Capability . OPTIONAL_REASONING , true )
. SetOverride ( Capability . REASONING_BY_DEFAULT , false ),
ReasoningOverrideMode . ON_BY_DEFAULT => this . capabilityOverrides
. SetOverride ( Capability . ALWAYS_REASONING , false )
. SetOverride ( Capability . OPTIONAL_REASONING , true )
. SetOverride ( Capability . REASONING_BY_DEFAULT , true ),
ReasoningOverrideMode . ALWAYS_ON => this . capabilityOverrides
. SetOverride ( Capability . ALWAYS_REASONING , true )
. SetOverride ( Capability . OPTIONAL_REASONING , false )
. SetOverride ( Capability . REASONING_BY_DEFAULT , false ),
_ => this . capabilityOverrides
};
}
private string GetReasoningOverrideModeLabel ( ReasoningOverrideMode mode ) => mode switch
{
ReasoningOverrideMode . AUTOMATIC => T ( "Automatic" ),
ReasoningOverrideMode . NO_REASONING => T ( "No reasoning (thinking)" ),
ReasoningOverrideMode . CAN_BE_ENABLED => T ( "Can be enabled" ),
ReasoningOverrideMode . ON_BY_DEFAULT => T ( "On by default" ),
ReasoningOverrideMode . ALWAYS_ON => T ( "Always on" ),
_ => mode . ToString ()
};
private string GetReasoningOverrideModeDescription ( ReasoningOverrideMode mode ) => mode switch
{
ReasoningOverrideMode . AUTOMATIC => string . Format ( T ( "Use detected model behavior: {0}." ), this . GetReasoningOverrideModeLabel ( this . GetAutomaticReasoningOverrideMode ())),
ReasoningOverrideMode . NO_REASONING => T ( "No reasoning (thinking) capability." ),
ReasoningOverrideMode . CAN_BE_ENABLED => T ( "Reasoning (thinking) is available, but off unless additional API parameters enable it." ),
ReasoningOverrideMode . ON_BY_DEFAULT => T ( "Reasoning (thinking) is available and on unless additional API parameters disable it." ),
ReasoningOverrideMode . ALWAYS_ON => T ( "The model always uses reasoning (thinking); it cannot be disabled." ),
_ => string . Empty
};
private bool HasCapabilityOverride ( Capability capability ) => this . capabilityOverrides . GetOverride ( capability ) is not null ;
2026-09-13 14:17:25 +02:00
private bool IsCapabilityEnabled ( Capability capability ) => this . GetCurrentModelProfile (). Has ( capability );
2026-07-05 12:42:55 +02:00
private string GetCapabilityEffectiveLabel ( Capability capability )
{
var isEnabled = this . IsCapabilityEnabled ( capability );
if ( this . HasCapabilityOverride ( capability ))
return isEnabled ? T ( "Enabled" ) : T ( "Disabled" );
return isEnabled ? T ( "Enabled (Auto)" ) : T ( "Disabled (Auto)" );
}
2026-09-13 14:17:25 +02:00
/// <summary>
/// States how many tokens this installation reads and writes.
/// </summary>
/// <param name="tokens">The number of tokens, or null to go back to the automatic answer.</param>
private void SetContextWindowOverride ( int? tokens ) => this . capabilityOverrides = this . capabilityOverrides with { ContextWindowTokens = tokens };
/// <summary>
/// States how many images one message may carry here.
/// </summary>
/// <param name="images">The number of images, or null to go back to the automatic answer.</param>
private void SetMaxImagesPerMessageOverride ( int? images ) => this . capabilityOverrides = this . capabilityOverrides with { MaxImagesPerMessage = images };
/// <summary>
/// States how many images one request may carry here.
/// </summary>
/// <param name="images">The number of images, or null to go back to the automatic answer.</param>
private void SetMaxImagesPerRequestOverride ( int? images ) => this . capabilityOverrides = this . capabilityOverrides with { MaxImagesPerRequest = images };
/// <summary>
/// What an empty window field shows.
/// </summary>
/// <remarks>
/// Written without separators, unlike the number in the helper text next to it: this one stands
/// inside the field a person types into, and what they see there has to be what they may type.
/// </remarks>
private string AutomaticContextWindowPlaceholder
{
get
{
var context = this . GetAutomaticModelProfile (). Context ;
return context . IsKnown ? context . DefaultTokens . ToString ( CultureInfo . InvariantCulture ) : string . Empty ;
}
}
/// <summary>
/// What an empty image field shows.
/// </summary>
/// <param name="limit">The limit the rules worked out, if any.</param>
/// <returns>The number, or nothing where nobody stated one.</returns>
private string AutomaticImageLimitPlaceholder ( int? limit ) => limit ?. ToString ( CultureInfo . InvariantCulture ) ?? string . Empty ;
/// <summary>
/// What the window field says below itself.
/// </summary>
/// <remarks>
/// It names the automatic answer rather than the one in effect, because the number in effect is
/// already in the field. What a person cannot otherwise see is what they would go back to.
/// </remarks>
private string ContextWindowHelperText
2026-07-05 12:42:55 +02:00
{
2026-09-13 14:17:25 +02:00
get
{
var context = this . GetAutomaticModelProfile (). Context ;
return context . IsKnown
? string . Format ( T ( "Detected: {0} tokens. Leave the field empty to use that." ), context . DefaultTokens . ToString ( "N0" , this . currentCulture ))
: T ( "Nobody has stated a window for this model. Left empty, the chat counts the tokens of a conversation without saying what they may grow to." );
}
2026-07-05 12:42:55 +02:00
}
2026-09-13 14:17:25 +02:00
/// <summary>
/// What the two image fields say above themselves.
/// </summary>
/// <remarks>
/// The number in effect, not the two the person typed: which of them decides is the one thing
/// two fields cannot show on their own, and it is the one the chat and the Visual Briefing go by.
/// </remarks>
private string ImageLimitsEffectiveLabel
{
get
{
var allowed = this . GetCurrentModelProfile (). Images . MaxInOneMessage ;
return allowed is { } count
? string . Format ( T ( "At most {0} images at once." ), count . ToString ( "N0" , this . currentCulture ))
: T ( "No limit known, so AI Studio does not stop anybody from attaching more." );
}
}
/// <summary>
/// What the model can do as this provider instance is configured, the person's own settings included.
/// </summary>
/// <returns>The profile.</returns>
private ModelProfile GetCurrentModelProfile () => this . CreateProviderSettings (). GetModelProfile ();
/// <summary>
/// What holds without anybody switching anything, which is what each field shows as its automatic answer.
/// </summary>
/// <remarks>
/// The rules, plus whatever the provider itself stated when the model list was loaded a moment
/// ago. A self-hosted engine is the case this matters for: it reports the window it was started
/// with, and that is the number a person gets by leaving the field below empty.
/// </remarks>
/// <returns>The profile.</returns>
private ModelProfile GetAutomaticModelProfile () => this . CreateProviderSettings (). GetAutomaticModelProfile ();
2026-07-05 12:42:55 +02:00
private string GetCurrentModelApiLabel ()
{
2026-09-13 14:17:25 +02:00
var profile = this . GetCurrentModelProfile ();
if ( profile . Has ( Capability . RESPONSES_API ))
2026-07-05 12:42:55 +02:00
return "Responses API" ;
2026-09-13 14:17:25 +02:00
if ( profile . Has ( Capability . CHAT_COMPLETION_API ))
2026-07-05 12:42:55 +02:00
return "Chat Completions API" ;
return "Unknown" ;
}
private string GetCapabilityOverrideLabel ( Capability capability ) => capability switch
{
Capability . AUDIO_INPUT => T ( "Audio input" ),
2026-09-04 15:48:07 +02:00
Capability . FUNCTION_CALLING => T ( "Tool calling" ),
2026-07-05 12:42:55 +02:00
Capability . MULTIPLE_IMAGE_INPUT => T ( "Multiple image input" ),
Capability . SPEECH_INPUT => T ( "Speech input" ),
Capability . VIDEO_INPUT => T ( "Video input" ),
Capability . ALWAYS_REASONING => T ( "Always reasoning" ),
_ => capability . ToString ()
};
2025-11-13 18:13:16 +01:00
private void OnInputChangeExpertSettings ()
{
2026-03-12 12:11:54 +01:00
this . AdditionalJsonApiParameters = NormalizeAdditionalJsonApiParameters ( this . AdditionalJsonApiParameters )
. Trim ()
. TrimEnd ( ',' , ' ' );
}
private string? ValidateAdditionalJsonApiParameters ( string additionalParams )
{
if ( string . IsNullOrWhiteSpace ( additionalParams ))
return null ;
var normalized = NormalizeAdditionalJsonApiParameters ( additionalParams );
if (! string . Equals ( normalized , additionalParams , StringComparison . Ordinal ))
this . AdditionalJsonApiParameters = normalized ;
var json = $"{{{normalized}}}" ;
try
{
if (! this . TryValidateJsonObjectWithDuplicateCheck ( json , out var errorMessage ))
return errorMessage ;
return null ;
}
catch ( JsonException )
{
2026-08-25 16:53:19 +02:00
return T ( """Invalid JSON: Add the parameters in proper JSON formatting, e.g., " temperature ": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though.""" );
2026-03-12 12:11:54 +01:00
}
}
private static string NormalizeAdditionalJsonApiParameters ( string input )
{
var sb = new StringBuilder ( input . Length );
var inString = false ;
var escape = false ;
for ( var i = 0 ; i < input . Length ; i ++)
{
var c = input [ i ];
if ( inString )
{
sb . Append ( c );
if ( escape )
{
escape = false ;
continue ;
}
if ( c == '\\' )
{
escape = true ;
continue ;
}
if ( c == '"' )
inString = false ;
continue ;
}
if ( c == '"' )
{
inString = true ;
sb . Append ( c );
continue ;
}
if ( TryReadToken ( input , i , "True" , out var tokenLength ))
{
sb . Append ( "true" );
i += tokenLength - 1 ;
continue ;
}
if ( TryReadToken ( input , i , "False" , out tokenLength ))
{
sb . Append ( "false" );
i += tokenLength - 1 ;
continue ;
}
if ( TryReadToken ( input , i , "Null" , out tokenLength ))
{
sb . Append ( "null" );
i += tokenLength - 1 ;
continue ;
}
sb . Append ( c );
}
return sb . ToString ();
}
private static bool TryReadToken ( string input , int startIndex , string token , out int tokenLength )
{
tokenLength = 0 ;
if ( startIndex + token . Length > input . Length )
return false ;
if (! input . AsSpan ( startIndex , token . Length ). SequenceEqual ( token ))
return false ;
var beforeIndex = startIndex - 1 ;
if ( beforeIndex >= 0 && IsIdentifierChar ( input [ beforeIndex ]))
return false ;
var afterIndex = startIndex + token . Length ;
if ( afterIndex < input . Length && IsIdentifierChar ( input [ afterIndex ]))
return false ;
tokenLength = token . Length ;
return true ;
}
private static bool IsIdentifierChar ( char c ) => char . IsLetterOrDigit ( c ) || c == '_' ;
private bool TryValidateJsonObjectWithDuplicateCheck ( string json , out string? errorMessage )
{
errorMessage = null ;
var bytes = Encoding . UTF8 . GetBytes ( json );
var reader = new Utf8JsonReader ( bytes , new JsonReaderOptions
{
AllowTrailingCommas = false ,
CommentHandling = JsonCommentHandling . Disallow
});
var objectStack = new Stack < HashSet < string >>();
while ( reader . Read ())
{
switch ( reader . TokenType )
{
case JsonTokenType . StartObject :
objectStack . Push ( new HashSet < string >( StringComparer . Ordinal ));
break ;
case JsonTokenType . EndObject :
if ( objectStack . Count > 0 )
objectStack . Pop ();
break ;
case JsonTokenType . PropertyName :
if ( objectStack . Count == 0 )
{
errorMessage = T ( "Additional API parameters must form a JSON object." );
return false ;
}
var name = reader . GetString () ?? string . Empty ;
if (! objectStack . Peek (). Add ( name ))
{
errorMessage = string . Format ( T ( "Duplicate key '{0}' found." ), name );
return false ;
}
break ;
}
}
if ( objectStack . Count != 0 )
{
2026-08-25 16:53:19 +02:00
errorMessage = T ( """Invalid JSON: Add the parameters in proper JSON formatting, e.g., " temperature ": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though.""" );
2026-03-12 12:11:54 +01:00
return false ;
}
return true ;
2025-11-13 18:13:16 +01:00
}
2026-09-09 18:43:37 +02:00
2025-11-13 18:13:16 +01:00
private string GetExpertStyles => this . showExpertSettings ? "border-2 border-dashed rounded pa-2" : string . Empty ;
2026-09-09 18:43:37 +02:00
private static string GetPlaceholderExpertSettings =>
2025-11-13 18:13:16 +01:00
"""
"temperature" : 0.5 ,
"top_p" : 0.9 ,
"frequency_penalty" : 0.0
""";
2026-03-12 12:11:54 +01:00
}