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).