mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-29 05:23:38 +00:00
Resolved 29 conflicting files. The notable decisions: Confidence: main's tool-calling gate (RequiredProviderConfidence) and this branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule on the same axis, so they are now one field. Both tool results and data sources raise it through RequireProviderConfidence(). The gate checks the level strictly and no longer exempts providers trusted by configuration: TrustedProviderIds is documented as applying to data-source security checks only, and organizations set confidence through DataConfidence .CustomConfidenceScheme instead. The security axis (DataSecurity, ERI, IsTrustedForDataSourceSecurityChecks) is unchanged. Provider creation: main's CreateProvider signature won (hfEndpointKind, capabilityOverrides, no model parameter); tokenizerPath was added to it and is set for every provider, including the new Hetzner, IONOS and LiteLLM. Provider and EmbeddingProvider combine the record parameters, Lua parsing and Lua serialization of both sides. File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the runtime already reads. Both sides had added a conflicting HTML filter; the reading family keeps the name, and the export path uses a narrow HTML_DOCUMENT, following the existing LATEX/TEX split. Runtime: main's file_data.rs is the base, including the prompt-injection sanitizer and the extraction routes. Token counting and chunk segmentation moved into take_released, so they act on the text the filter has released rather than on text it is still holding. A failed count is logged and left out instead of ending the extraction, because the app counts such a segment itself. Data sources: the participating-provider checks of this branch are kept, and main's GetAllowedDataSources overload now builds on them. DirectChatService resolves the launched chat's data source options before the check, so filter and chat see the same options. .NET and Rust both build clean; I18N regenerated to 4060 keys.
917 lines
34 KiB
C#
917 lines
34 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
using AIStudio.Components;
|
|
using AIStudio.Provider;
|
|
using AIStudio.Provider.HuggingFace;
|
|
using AIStudio.Tools.Rust;
|
|
using AIStudio.Settings;
|
|
using AIStudio.Tools.Services;
|
|
using AIStudio.Tools.Validation;
|
|
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Web;
|
|
|
|
using Host = AIStudio.Provider.SelfHosted.Host;
|
|
|
|
namespace AIStudio.Dialogs;
|
|
|
|
/// <summary>
|
|
/// The provider settings dialog.
|
|
/// </summary>
|
|
public partial class ProviderDialog : MSGComponentBase, ISecretId
|
|
{
|
|
private enum ReasoningOverrideMode
|
|
{
|
|
AUTOMATIC,
|
|
NO_REASONING,
|
|
CAN_BE_ENABLED,
|
|
ON_BY_DEFAULT,
|
|
ALWAYS_ON
|
|
}
|
|
|
|
[CascadingParameter]
|
|
private IMudDialogInstance MudDialog { get; set; } = null!;
|
|
|
|
/// <summary>
|
|
/// The provider's number in the list.
|
|
/// </summary>
|
|
[Parameter]
|
|
public uint DataNum { get; set; }
|
|
|
|
/// <summary>
|
|
/// The provider's ID.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string DataId { get; set; } = Guid.NewGuid().ToString();
|
|
|
|
/// <summary>
|
|
/// The user chosen instance name.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string DataInstanceName { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// The chosen hostname for self-hosted providers.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string DataHostname { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// The host to use, e.g., llama.cpp.
|
|
/// </summary>
|
|
[Parameter]
|
|
public Host DataHost { get; set; } = Host.NONE;
|
|
|
|
/// <summary>
|
|
/// The HFInstanceProvider to use, e.g., CEREBRAS.
|
|
/// </summary>
|
|
[Parameter]
|
|
public HFInferenceProvider HFInferenceProviderId { get; set; } = HFInferenceProvider.NONE;
|
|
|
|
/// <summary>
|
|
/// Is this provider self-hosted?
|
|
/// </summary>
|
|
[Parameter]
|
|
public bool IsSelfHosted { get; set; }
|
|
|
|
/// <summary>
|
|
/// The provider to use.
|
|
/// </summary>
|
|
[Parameter]
|
|
public LLMProviders DataLLMProvider { get; set; } = LLMProviders.NONE;
|
|
|
|
/// <summary>
|
|
/// The validated custom icon supplied by a configuration plugin.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string DataCustomIconDataUrl { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// The LLM model to use, e.g., GPT-4o.
|
|
/// </summary>
|
|
[Parameter]
|
|
public Model DataModel { get; set; }
|
|
|
|
/// <summary>
|
|
/// Should the dialog be in editing mode?
|
|
/// </summary>
|
|
[Parameter]
|
|
public bool IsEditing { get; init; }
|
|
|
|
/// <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; }
|
|
|
|
[Parameter]
|
|
public string AdditionalJsonApiParameters { get; set; } = string.Empty;
|
|
|
|
[Parameter]
|
|
public string DataTokenizerPath { get; set; } = string.Empty;
|
|
|
|
[Parameter]
|
|
public ProviderCapabilityOverrides? DataCapabilityOverrides { get; set; }
|
|
|
|
[Inject]
|
|
private RustService RustService { get; init; } = null!;
|
|
|
|
[Inject]
|
|
private ILogger<ProviderDialog> Logger { get; init; } = null!;
|
|
|
|
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
|
|
private static readonly IReadOnlyList<Capability> SWITCH_CAPABILITY_OVERRIDES =
|
|
[
|
|
Capability.AUDIO_INPUT,
|
|
Capability.FUNCTION_CALLING,
|
|
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
|
|
];
|
|
|
|
/// <summary>
|
|
/// The list of used instance names. We need this to check for uniqueness.
|
|
/// </summary>
|
|
private List<string> UsedInstanceNames { get; set; } = [];
|
|
|
|
private bool dataIsValid;
|
|
private string[] dataIssues = [];
|
|
private string dataAPIKey = string.Empty;
|
|
private bool dataHadStoredAPIKeyOnLoad;
|
|
private string dataManuallyModel = string.Empty;
|
|
private string dataAPIKeyStorageIssue = string.Empty;
|
|
private string dataEditingPreviousInstanceName = string.Empty;
|
|
private string dataLoadingModelsIssue = string.Empty;
|
|
private string dataFilePath = string.Empty;
|
|
private string dataCustomTokenizerValidationIssue = string.Empty;
|
|
private Task dataTokenizerValidationTask = Task.CompletedTask;
|
|
private bool dataStoreWasAttempted;
|
|
private bool isTokenizerFileDialogOpen;
|
|
private int dataTokenizerValidationRevision;
|
|
private bool usesLegacySystemModelFallback;
|
|
private bool showExpertSettings;
|
|
private ProviderCapabilityOverrides capabilityOverrides = new();
|
|
|
|
// We get the form reference from Blazor code to validate it manually:
|
|
private MudForm form = null!;
|
|
|
|
private readonly List<Model> availableModels = new();
|
|
private readonly Encryption encryption = Program.ENCRYPTION;
|
|
private readonly ProviderValidation providerValidation;
|
|
|
|
public ProviderDialog()
|
|
{
|
|
this.providerValidation = new()
|
|
{
|
|
GetProvider = () => this.DataLLMProvider,
|
|
GetAPIKeyStorageIssue = () => this.dataAPIKeyStorageIssue,
|
|
GetPreviousInstanceName = () => this.dataEditingPreviousInstanceName,
|
|
GetUsedInstanceNames = () => this.UsedInstanceNames,
|
|
GetHost = () => this.DataHost,
|
|
IsModelProvidedManually = () => this.DataLLMProvider.IsLLMModelProvidedManually(),
|
|
GetCustomTokenizerValidationIssue = () => this.dataCustomTokenizerValidationIssue,
|
|
IsModelSelectionHidden = () => this.IsLLMModelSelectionHidden,
|
|
};
|
|
}
|
|
|
|
private AIStudio.Settings.Provider CreateProviderSettings()
|
|
{
|
|
var cleanedHostname = this.DataHostname.Trim();
|
|
|
|
return new()
|
|
{
|
|
Num = this.DataNum,
|
|
Id = this.DataId,
|
|
InstanceName = this.DataInstanceName,
|
|
UsedLLMProvider = this.DataLLMProvider,
|
|
Model = this.GetSelectedModel(),
|
|
IsSelfHosted = this.DataLLMProvider is LLMProviders.SELF_HOSTED,
|
|
IsEnterpriseConfiguration = this.IsEnterpriseConfiguration,
|
|
Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname,
|
|
Host = this.DataHost,
|
|
HFInferenceProvider = this.HFInferenceProviderId,
|
|
AdditionalJsonApiParameters = this.AdditionalJsonApiParameters,
|
|
TokenizerPath = this.dataFilePath,
|
|
CapabilityOverrides = this.capabilityOverrides.HasOverrides ? this.capabilityOverrides : null,
|
|
CustomIconDataUrl = this.DataCustomIconDataUrl,
|
|
};
|
|
}
|
|
|
|
private Model GetSelectedModel()
|
|
{
|
|
if (this.IsLLMModelSelectionHidden)
|
|
return Model.SYSTEM_MODEL;
|
|
|
|
if (this.DataLLMProvider.IsLLMModelProvidedManually())
|
|
return new Model(this.dataManuallyModel, null);
|
|
|
|
return this.DataModel;
|
|
}
|
|
|
|
#region Overrides of ComponentBase
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
// Call the base initialization first so that the I18N is ready:
|
|
await base.OnInitializedAsync();
|
|
|
|
// Configure the spellchecking for the instance name input:
|
|
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
|
|
|
|
// Load the used instance names:
|
|
this.UsedInstanceNames = this.SettingsManager.GetAllProviders().Select(x => x.InstanceName.ToLowerInvariant()).ToList();
|
|
|
|
this.capabilityOverrides = this.DataCapabilityOverrides ?? new();
|
|
this.showExpertSettings = !string.IsNullOrWhiteSpace(this.AdditionalJsonApiParameters) || this.capabilityOverrides.HasOverrides;
|
|
|
|
// When editing, we need to load the data:
|
|
if(this.IsEditing)
|
|
{
|
|
this.dataEditingPreviousInstanceName = this.DataInstanceName.ToLowerInvariant();
|
|
this.dataFilePath = this.DataTokenizerPath;
|
|
|
|
// When using Fireworks, we must copy the model name:
|
|
if (this.DataLLMProvider.IsLLMModelProvidedManually())
|
|
this.dataManuallyModel = this.DataModel.Id;
|
|
|
|
//
|
|
// We cannot load the API key for self-hosted providers:
|
|
//
|
|
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && this.DataHost is not Host.OLLAMA && this.DataHost is not Host.VLLM)
|
|
{
|
|
await this.ReloadModels();
|
|
await base.OnInitializedAsync();
|
|
return;
|
|
}
|
|
|
|
// Load the API key:
|
|
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.LLM_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
|
|
if (requestedSecret.Success)
|
|
{
|
|
this.dataAPIKey = await requestedSecret.Secret.Decrypt(this.encryption);
|
|
this.dataHadStoredAPIKeyOnLoad = !string.IsNullOrWhiteSpace(this.dataAPIKey);
|
|
}
|
|
else
|
|
{
|
|
this.dataAPIKey = string.Empty;
|
|
|
|
// 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)
|
|
{
|
|
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);
|
|
await this.form.Validate();
|
|
}
|
|
}
|
|
|
|
await this.ReloadModels();
|
|
}
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
// 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.
|
|
if(!this.IsEditing && firstRender)
|
|
this.form.ResetValidation();
|
|
|
|
await base.OnAfterRenderAsync(firstRender);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Implementation of ISecretId
|
|
|
|
// 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();
|
|
|
|
public string SecretName => this.DataInstanceName;
|
|
|
|
#endregion
|
|
|
|
private async Task Store()
|
|
{
|
|
this.dataStoreWasAttempted = true;
|
|
await this.dataTokenizerValidationTask;
|
|
await this.form.Validate();
|
|
if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
|
|
this.dataAPIKeyStorageIssue = string.Empty;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// When the data is not valid, we don't store it:
|
|
if (!this.dataIsValid)
|
|
return;
|
|
|
|
var tokenizerResponse = await this.StoreOrDeleteTokenizerAsync();
|
|
if (!tokenizerResponse.Success)
|
|
{
|
|
this.dataCustomTokenizerValidationIssue = tokenizerResponse.Message;
|
|
await this.form.Validate();
|
|
return;
|
|
}
|
|
this.dataFilePath = tokenizerResponse.StoredPath;
|
|
|
|
// Use the data model to store the provider.
|
|
// We just return this data to the parent component:
|
|
var addedProviderSettings = this.CreateProviderSettings();
|
|
if (!string.IsNullOrWhiteSpace(this.dataAPIKey))
|
|
{
|
|
// Store the API key in the OS secure storage:
|
|
var storeResponse = await this.RustService.SetAPIKey(this, this.dataAPIKey, SecretStoreType.LLM_PROVIDER);
|
|
if (!storeResponse.Success)
|
|
{
|
|
this.dataAPIKeyStorageIssue = string.Format(T("Failed to store the API key in the operating system. The message was: {0}. Please try again."), storeResponse.Issue);
|
|
await this.form.Validate();
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
this.MudDialog.Close(DialogResult.Ok(addedProviderSettings));
|
|
}
|
|
|
|
private string? ValidateManuallyModel(string manuallyModel)
|
|
{
|
|
if (this.DataLLMProvider.IsLLMModelProvidedManually() && string.IsNullOrWhiteSpace(manuallyModel))
|
|
return T("Please enter a model name.");
|
|
|
|
return null;
|
|
}
|
|
|
|
private void Cancel() => this.MudDialog.Cancel();
|
|
|
|
private async Task OnAPIKeyChanged(string apiKey)
|
|
{
|
|
this.dataAPIKey = apiKey;
|
|
if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
|
|
{
|
|
this.dataAPIKeyStorageIssue = string.Empty;
|
|
await this.form.Validate();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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.DeleteTokenizer(tokenizerId);
|
|
|
|
return this.RustService.StoreTokenizer(tokenizerId, this.dataFilePath);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
private void OnHostChanged(Host selectedHost)
|
|
{
|
|
// When the host changes, reset the model selection state:
|
|
this.DataHost = selectedHost;
|
|
this.DataModel = default;
|
|
this.dataManuallyModel = string.Empty;
|
|
this.capabilityOverrides = new();
|
|
this.availableModels.Clear();
|
|
this.dataLoadingModelsIssue = string.Empty;
|
|
this.usesLegacySystemModelFallback = false;
|
|
}
|
|
|
|
private Task OnModelChanged(Model selectedModel)
|
|
{
|
|
this.DataModel = selectedModel;
|
|
this.capabilityOverrides = new();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task ReloadModels()
|
|
{
|
|
this.dataLoadingModelsIssue = string.Empty;
|
|
var currentProviderSettings = this.CreateProviderSettings();
|
|
var provider = currentProviderSettings.CreateProvider();
|
|
if (provider is NoProvider)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var result = await provider.GetTextModels(this.dataAPIKey);
|
|
if (!result.Success)
|
|
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
|
|
|
// Order descending by ID means that the newest models probably come first:
|
|
var orderedModels = result.Models.OrderByDescending(n => n.Id);
|
|
|
|
this.availableModels.Clear();
|
|
this.availableModels.AddRange(orderedModels);
|
|
this.UpdateModelSelectionAfterLoading();
|
|
}
|
|
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.");
|
|
}
|
|
}
|
|
|
|
private string APIKeyText => this.DataLLMProvider switch
|
|
{
|
|
LLMProviders.SELF_HOSTED => T("(Optional) API Key"),
|
|
_ => T("API Key"),
|
|
};
|
|
|
|
private bool IsLLMModelSelectionHidden => this.DataLLMProvider.IsLLMModelSelectionHidden(this.DataHost) ||
|
|
this.DataLLMProvider is LLMProviders.SELF_HOSTED &&
|
|
this.DataHost is Host.LLAMA_CPP &&
|
|
this.usesLegacySystemModelFallback;
|
|
|
|
/// <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);
|
|
|
|
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;
|
|
}
|
|
|
|
private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings;
|
|
|
|
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;
|
|
|
|
var capabilities = this.GetCurrentModelCapabilities();
|
|
if (capabilities.Contains(Capability.ALWAYS_REASONING))
|
|
return ReasoningOverrideMode.ALWAYS_ON;
|
|
|
|
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT))
|
|
return ReasoningOverrideMode.ON_BY_DEFAULT;
|
|
|
|
if (capabilities.Contains(Capability.OPTIONAL_REASONING))
|
|
return ReasoningOverrideMode.CAN_BE_ENABLED;
|
|
|
|
return ReasoningOverrideMode.NO_REASONING;
|
|
}
|
|
|
|
private ReasoningOverrideMode GetAutomaticReasoningOverrideMode()
|
|
{
|
|
var capabilities = this.GetAutomaticModelCapabilities();
|
|
if (capabilities.Contains(Capability.ALWAYS_REASONING))
|
|
return ReasoningOverrideMode.ALWAYS_ON;
|
|
|
|
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT))
|
|
return ReasoningOverrideMode.ON_BY_DEFAULT;
|
|
|
|
if (capabilities.Contains(Capability.OPTIONAL_REASONING))
|
|
return ReasoningOverrideMode.CAN_BE_ENABLED;
|
|
|
|
return ReasoningOverrideMode.NO_REASONING;
|
|
}
|
|
|
|
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;
|
|
|
|
private bool IsCapabilityEnabled(Capability capability)
|
|
{
|
|
var capabilities = this.GetCurrentModelCapabilities();
|
|
return capabilities.Contains(capability);
|
|
}
|
|
|
|
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)");
|
|
}
|
|
|
|
private List<Capability> GetCurrentModelCapabilities()
|
|
{
|
|
var currentProviderSettings = this.CreateProviderSettings();
|
|
return currentProviderSettings.GetModelCapabilities();
|
|
}
|
|
|
|
private List<Capability> GetAutomaticModelCapabilities() => this.DataLLMProvider.GetModelCapabilities(this.GetSelectedModel());
|
|
|
|
private string GetCurrentModelApiLabel()
|
|
{
|
|
var capabilities = this.GetCurrentModelCapabilities();
|
|
if (capabilities.Contains(Capability.RESPONSES_API))
|
|
return "Responses API";
|
|
|
|
if (capabilities.Contains(Capability.CHAT_COMPLETION_API))
|
|
return "Chat Completions API";
|
|
|
|
return "Unknown";
|
|
}
|
|
|
|
private string GetCapabilityOverrideLabel(Capability capability) => capability switch
|
|
{
|
|
Capability.AUDIO_INPUT => T("Audio input"),
|
|
Capability.FUNCTION_CALLING => T("Tool calling"),
|
|
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()
|
|
};
|
|
|
|
private void OnInputChangeExpertSettings()
|
|
{
|
|
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)
|
|
{
|
|
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.""");
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
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.""");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
|
|
|
|
private static string GetPlaceholderExpertSettings =>
|
|
"""
|
|
"temperature": 0.5,
|
|
"top_p": 0.9,
|
|
"frequency_penalty": 0.0
|
|
""";
|
|
}
|