From 84763f390747e1e49e01d8346df206d6a1050e8e Mon Sep 17 00:00:00 2001 From: Dominic Neuburg Date: Thu, 13 Aug 2026 11:31:11 +0200 Subject: [PATCH] Added per-provider user-managed API keys for enterprise-configured LLM providers --- .../Assistants/I18N/allTexts.lua | 6 +++ .../Settings/SettingsPanelProviders.razor | 8 +++- .../Settings/SettingsPanelProviders.razor.cs | 23 ++++++++--- .../Dialogs/ProviderDialog.razor | 28 +++++++++---- .../Dialogs/ProviderDialog.razor.cs | 22 ++++++++-- .../Plugins/configuration/plugin.lua | 8 ++++ .../plugin.lua | 6 +++ app/MindWork AI Studio/Settings/Provider.cs | 20 +++++++-- .../PluginSystem/PluginConfigurationObject.cs | 7 ++++ documentation/Enterprise IT.md | 41 +++++++++++++++++++ 10 files changed, 147 insertions(+), 22 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 3c8925f6..a402b3f2 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -4429,6 +4429,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T426925 -- This self-hosted provider is trusted for data source security checks. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T485526152"] = "This self-hosted provider is trusted for data source security checks." +-- This provider is managed by your organization. You can set your own API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T579100747"] = "This provider is managed by your organization. You can set your own API key." + -- Open Dashboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T78223861"] = "Open Dashboard" @@ -6085,6 +6088,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T4250996615"] = "P -- Hugging Face Inference Provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1085481431"] = "Hugging Face Inference Provider" +-- This provider is managed by your organization. Host, model, and other settings are locked. You can set your own API key below. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1090492389"] = "This provider is managed by your organization. Host, model, and other settings are locked. You can set your own API key below." + -- Hide Expert Settings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1108876344"] = "Hide Expert Settings" diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index 5ec93e3e..151b9947 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -37,12 +37,18 @@ } - @if (context.IsEnterpriseConfiguration) + @if (context.IsEnterpriseConfiguration && !context.AllowUserProvidedAPIKey) { } + else if (context.IsEnterpriseConfiguration && context.AllowUserProvidedAPIKey) + { + + + + } else { diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs index 4e86eed9..3e4aea00 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs @@ -55,10 +55,10 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase { if(provider == AIStudio.Settings.Provider.NONE) return; - - if (provider.IsEnterpriseConfiguration) + + if (provider.IsEnterpriseConfiguration && !provider.AllowUserProvidedAPIKey) return; - + var dialogParameters = new DialogParameters { { x => x.DataNum, provider.Num }, @@ -73,6 +73,7 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase { x => x.HFInferenceProviderId, provider.HFInferenceProvider }, { x => x.AdditionalJsonApiParameters, provider.AdditionalJsonApiParameters }, { x => x.DataCapabilityOverrides, provider.CapabilityOverrides }, + { x => x.IsEnterpriseConfiguration, provider.IsEnterpriseConfiguration }, }; var dialogReference = await this.DialogService.ShowAsync(T("Edit LLM Provider"), dialogParameters, DialogOptions.FULLSCREEN); @@ -80,16 +81,26 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase if (dialogResult is null || dialogResult.Canceled) return; + if (provider.IsEnterpriseConfiguration) + { + // Only the API key changed, and the dialog already stored it directly. The provider + // object itself is managed by the configuration plugin and must not be overwritten + // with the dialog's copy -- doing so would let the locked-but-technically-editable + // fields drift from what the organization configured. + await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); + return; + } + var editedProvider = (AIStudio.Settings.Provider)dialogResult.Data!; - + // Set the provider number if it's not set. This is important for providers // added before we started saving the provider number. if(editedProvider.Num == 0) editedProvider = editedProvider with { Num = this.SettingsManager.ConfigurationData.NextProviderNum++ }; - + this.SettingsManager.ConfigurationData.Providers[this.SettingsManager.ConfigurationData.Providers.IndexOf(provider)] = editedProvider; await this.UpdateProviders(); - + await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor index 85795de9..b896837e 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor @@ -4,6 +4,12 @@ @inherits MSGComponentBase + @if (this.IsEnterpriseConfiguration) + { + + @T("This provider is managed by your organization. Host, model, and other settings are locked. You can set your own API key below.") + + } @* ReSharper disable once CSharpWarnings::CS8974 *@ @@ -15,6 +21,7 @@ OpenIcon="@Icons.Material.Filled.AccountBalance" AdornmentColor="Color.Info" Adornment="Adornment.Start" + Disabled="@this.IsEnterpriseConfiguration" Validation="@this.providerValidation.ValidatingProvider"> @foreach (LLMProviders provider in Enum.GetValues(typeof(LLMProviders))) { @@ -27,7 +34,7 @@ @T("Create account") - + @if (this.DataLLMProvider.IsAPIKeyNeeded(this.DataHost)) { @@ -43,13 +50,14 @@ Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Dns" AdornmentColor="Color.Info" + Disabled="@this.IsEnterpriseConfiguration" Validation="@this.providerValidation.ValidatingHostname" UserAttributes="@SPELLCHECK_ATTRIBUTES"/> } @if (this.DataLLMProvider.IsHostNeeded()) { - + @foreach (Host host in Enum.GetValues(typeof(Host))) { @if (host.IsChatSupported()) @@ -64,7 +72,7 @@ @if (this.DataLLMProvider.IsHFInstanceProviderNeeded()) { - + @foreach (HFInferenceProvider inferenceProvider in Enum.GetValues(typeof(HFInferenceProvider))) { @@ -95,6 +103,7 @@ Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.FaceRetouchingNatural" AdornmentColor="Color.Info" + Disabled="@this.IsEnterpriseConfiguration" Validation="@this.ValidateManuallyModel" UserAttributes="@SPELLCHECK_ATTRIBUTES" HelperText="@T("Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually.")" @@ -102,7 +111,7 @@ } else { - + @T("Load models") @if(this.availableModels.Count is 0) @@ -117,7 +126,7 @@ Value="@this.DataModel" ValueChanged="@(async model => await this.OnModelChanged(model))" OpenIcon="@Icons.Material.Filled.FaceRetouchingNatural" AdornmentColor="Color.Info" - Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingModel"> + Adornment="Adornment.Start" Disabled="@this.IsEnterpriseConfiguration" Validation="@this.providerValidation.ValidatingModel"> @foreach (var model in this.availableModels) { @@ -157,6 +166,7 @@ Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Lightbulb" AdornmentColor="Color.Info" + Disabled="@this.IsEnterpriseConfiguration" Validation="@this.providerValidation.ValidatingInstanceName" UserAttributes="@SPELLCHECK_ATTRIBUTES" /> @@ -193,12 +203,13 @@ @T("Reset") @@ -216,7 +227,8 @@ Margin="Margin.Dense" OpenIcon="@Icons.Material.Filled.Psychology" AdornmentColor="Color.Info" - Adornment="Adornment.Start"> + Adornment="Adornment.Start" + Disabled="@this.IsEnterpriseConfiguration"> @foreach (var mode in REASONING_OVERRIDE_MODES) { @@ -229,7 +241,7 @@ @string.Format(T("The current model uses the {0}."), this.GetCurrentModelApiLabel()) - + diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs index efa32f91..1bf82385 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs @@ -90,6 +90,13 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId /// [Parameter] public bool IsEditing { get; init; } + + /// + /// Whether this provider is managed by an enterprise configuration plugin. When true, every + /// field except the API key is locked, matching . + /// + [Parameter] + public bool IsEnterpriseConfiguration { get; set; } [Parameter] public string AdditionalJsonApiParameters { get; set; } = string.Empty; @@ -170,7 +177,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId UsedLLMProvider = this.DataLLMProvider, Model = this.GetSelectedModel(), IsSelfHosted = this.DataLLMProvider is LLMProviders.SELF_HOSTED, - IsEnterpriseConfiguration = false, + IsEnterpriseConfiguration = this.IsEnterpriseConfiguration, Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname, Host = this.DataHost, HFInferenceProvider = this.HFInferenceProviderId, @@ -234,7 +241,10 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId else { this.dataAPIKey = string.Empty; - if (this.DataLLMProvider is not LLMProviders.SELF_HOSTED) + + // 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(); @@ -259,8 +269,12 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId #region Implementation of ISecretId - public string SecretId => this.DataLLMProvider.ToSecretId(); - + // 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 diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 6851b529..7e1cc177 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -119,6 +119,14 @@ CONFIG["LLM_PROVIDERS"] = {} -- -- You can export an encrypted API key from an existing provider using the export button in the settings. -- -- ["APIKey"] = "ENC:v1:", -- +-- -- Optional: let each user set their own API key for this otherwise locked provider, +-- -- instead of (or in addition to not) embedding one centrally. Host, model, instance +-- -- name, and every other field stay locked; only the API key becomes editable. +-- -- Mutually exclusive with "APIKey" above: when both are set, the embedded key is +-- -- ignored and a warning is logged. The user's key is preserved in the OS keyring even +-- -- if this configuration is later withdrawn. +-- -- ["AllowUserProvidedAPIKey"] = true, +-- -- ["Model"] = { -- ["Id"] = "", -- ["DisplayName"] = "", diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 65f365f1..abd0250e 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -4431,6 +4431,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T426925 -- This self-hosted provider is trusted for data source security checks. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T485526152"] = "Dieser selbstgehostete Anbieter ist für Sicherheitsprüfungen von Datenquellen vertrauenswürdig." +-- This provider is managed by your organization. You can set your own API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T579100747"] = "Dieser Anbieter wird von Ihrer Organisation verwaltet. Sie können Ihren eigenen API-Schlüssel einrichten." + -- Open Dashboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T78223861"] = "Dashboard öffnen" @@ -6087,6 +6090,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T4250996615"] = "P -- Hugging Face Inference Provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1085481431"] = "Hugging Face Inferenz-Anbieter" +-- This provider is managed by your organization. Host, model, and other settings are locked. You can set your own API key below. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1090492389"] = "Dieser Provider wird von Ihrer Organisation verwaltet. Host, Modell und andere Einstellungen sind gesperrt. Sie können Ihren eigenen API-Schlüssel unten festlegen." + -- Hide Expert Settings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1108876344"] = "Experten-Einstellungen ausblenden" diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 4662b1b1..d6677f3c 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -21,6 +21,7 @@ namespace AIStudio.Settings; /// Whether the provider is self-hosted. /// The hostname of the provider. Useful for self-hosted providers. /// The LLM model to use for chat. +/// When set by a configuration plugin, the user may set their own API key for this otherwise locked, enterprise-managed provider. public sealed record Provider( uint Num, string Id, @@ -34,7 +35,8 @@ public sealed record Provider( Host Host = Host.NONE, HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE, string AdditionalJsonApiParameters = "", - ProviderCapabilityOverrides? CapabilityOverrides = null) : ConfigurationBaseObject, ISecretId + ProviderCapabilityOverrides? CapabilityOverrides = null, + bool AllowUserProvidedAPIKey = false) : ConfigurationBaseObject, ISecretId { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); @@ -155,6 +157,10 @@ public sealed record Provider( var capabilityOverrides = ProviderCapabilityOverrides.TryParseFromLuaTable(idx, table, configPluginId, LOGGER); + var allowUserProvidedApiKey = false; + if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead(out var allowUserProvidedApiKeyBool)) + allowUserProvidedApiKey = allowUserProvidedApiKeyBool; + provider = new Provider { Num = 0, // will be set later by the PluginConfigurationObject @@ -170,10 +176,18 @@ public sealed record Provider( HFInferenceProvider = hfInferenceProvider, AdditionalJsonApiParameters = additionalJsonApiParameters, CapabilityOverrides = capabilityOverrides, + AllowUserProvidedAPIKey = allowUserProvidedApiKey, }; - // Handle encrypted API key if present: - if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) + // Handle an encrypted API key if present. When the user manages their own key for this + // provider, we must never enqueue an embedded key: doing so would overwrite the user's + // key in the OS keyring on every configuration reload. + if (allowUserProvidedApiKey) + { + if (table.TryGetValue("APIKey", out var ignoredApiKeyValue) && ignoredApiKeyValue.TryRead(out var ignoredApiKeyText) && !string.IsNullOrWhiteSpace(ignoredApiKeyText)) + LOGGER.LogWarning($"The configured provider {idx} sets both AllowUserProvidedAPIKey and an embedded APIKey. Ignoring the embedded key: the user manages their own key for this provider. (Plugin ID: {configPluginId})"); + } + else if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) { if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index 40f45617..5398e133 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -407,6 +407,13 @@ public sealed record PluginConfigurationObject else LOG.LogWarning($"Failed to delete secret for removed enterprise object '{item.Name}' from the OS keyring: {deleteResult.Issue}"); } + else if(item is Settings.Provider { AllowUserProvidedAPIKey: true }) + { + // The user manages their own key for this provider. Keep it in the OS keyring + // in case the organization's configuration comes back later, instead of forcing + // the user to re-enter it: + LOG.LogInformation($"Preserving the user-provided API key for removed enterprise provider '{item.Name}' in the OS keyring."); + } else if(secretStoreType is not null && item is ISecretId secretId) { var deleteResult = await RustService.DeleteAPIKey(secretId, secretStoreType.Value); diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index 8f874ac4..6f77da3e 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -535,3 +535,44 @@ CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { ``` The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain). + +## Letting users provide their own API key + +Sometimes you want to hand out a preconfigured provider -- a fixed host, model, and instance name +-- without embedding a shared API key for it. Each user then brings their own key, for example +their personal OpenAI or Anthropic account, while everything else about the provider stays exactly +as your organization configured it. + +Set `AllowUserProvidedAPIKey` on the provider: + +```lua +CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { + ["Id"] = "9072b77d-ca81-40da-be6a-861da525ef7b", + ["InstanceName"] = "Corporate OpenAI GPT-4", + ["UsedLLMProvider"] = "OPEN_AI", + ["Host"] = "NONE", + ["Hostname"] = "", + ["AllowUserProvidedAPIKey"] = true, + ["AdditionalJsonApiParameters"] = "", + ["Model"] = { + ["Id"] = "gpt-4", + ["DisplayName"] = "GPT-4", + } +} +``` + +With `AllowUserProvidedAPIKey` set, the provider still shows up as managed by your organization, +and users still cannot change the host, model, instance name, or any other field. The settings +page shows a key icon instead of the usual lock icon for this provider; opening it only offers the +API key field, with everything else disabled. + +This is mutually exclusive with an embedded `APIKey` on the same provider: if both are present, +AI Studio ignores the embedded key and logs a warning, because the whole point of the flag is that +each user manages their own key. Combine the two across different providers if you need it -- one +provider with a shared, embedded key and another with `AllowUserProvidedAPIKey` -- but not on the +same provider. + +The user's key follows the same "withdrawing a configuration" philosophy as everything else in this +document: if your configuration stops offering this provider, AI Studio removes the provider from +the settings but leaves the user's key in the OS keyring rather than deleting it, in case the same +provider comes back later. See [Withdrawing a configuration](#withdrawing-a-configuration).