Added per-provider user-managed API keys for enterprise-configured LLM providers

This commit is contained in:
Dominic Neuburg 2026-08-13 11:31:11 +02:00
parent bb8f6f13f0
commit 84763f3907
10 changed files with 147 additions and 22 deletions

View File

@ -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"

View File

@ -37,12 +37,18 @@
<MudIconButton Color="Color.Success" Icon="@Icons.Material.Filled.VerifiedUser" Disabled="true"/>
</MudTooltip>
}
@if (context.IsEnterpriseConfiguration)
@if (context.IsEnterpriseConfiguration && !context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This provider is managed by your organization.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Business" Disabled="true"/>
</MudTooltip>
}
else if (context.IsEnterpriseConfiguration && context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This provider is managed by your organization. You can set your own API key.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Key" OnClick="@(() => this.EditLLMProvider(context))"/>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Open Dashboard")">

View File

@ -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<ProviderDialog>
{
{ 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<ProviderDialog>(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<bool>(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<bool>(this, Event.CONFIGURATION_CHANGED);
}

View File

@ -4,6 +4,12 @@
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
@if (this.IsEnterpriseConfiguration)
{
<MudAlert Severity="Severity.Info" Class="mb-4">
@T("This provider is managed by your organization. Host, model, and other settings are locked. You can set your own API key below.")
</MudAlert>
}
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
<MudStack Row="@true" AlignItems="AlignItems.Center">
@* 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")
</MudButton>
</MudStack>
@if (this.DataLLMProvider.IsAPIKeyNeeded(this.DataHost))
{
<SecretInputField Secret="@this.dataAPIKey" SecretChanged="@this.OnAPIKeyChanged" Label="@this.APIKeyText" Validation="@this.providerValidation.ValidatingAPIKey"/>
@ -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())
{
<MudSelect T="Host" Value="@this.DataHost" ValueChanged="@this.OnHostChanged" Label="@T("Host")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingHost">
<MudSelect T="Host" Value="@this.DataHost" ValueChanged="@this.OnHostChanged" Label="@T("Host")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Disabled="@this.IsEnterpriseConfiguration" Validation="@this.providerValidation.ValidatingHost">
@foreach (Host host in Enum.GetValues(typeof(Host)))
{
@if (host.IsChatSupported())
@ -64,7 +72,7 @@
@if (this.DataLLMProvider.IsHFInstanceProviderNeeded())
{
<MudSelect @bind-Value="@this.HFInferenceProviderId" Label="@T("Hugging Face Inference Provider")" Class="mb-3" OpenIcon="@Icons.Material.Filled.Dns" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingHFInstanceProvider">
<MudSelect @bind-Value="@this.HFInferenceProviderId" Label="@T("Hugging Face Inference Provider")" Class="mb-3" OpenIcon="@Icons.Material.Filled.Dns" AdornmentColor="Color.Info" Adornment="Adornment.Start" Disabled="@this.IsEnterpriseConfiguration" Validation="@this.providerValidation.ValidatingHFInstanceProvider">
@foreach (HFInferenceProvider inferenceProvider in Enum.GetValues(typeof(HFInferenceProvider)))
{
<MudSelectItem Value="@inferenceProvider">
@ -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
{
<MudButton Disabled="@(!this.DataLLMProvider.CanLoadModels(this.DataHost, this.dataAPIKey))" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@this.ReloadModels">
<MudButton Disabled="@(this.IsEnterpriseConfiguration || !this.DataLLMProvider.CanLoadModels(this.DataHost, this.dataAPIKey))" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@this.ReloadModels">
@T("Load models")
</MudButton>
@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)
{
<MudSelectItem Value="@model">
@ -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 @@
<MudSwitch T="bool"
Value="@this.IsCapabilityEnabled(capability)"
ValueChanged="@(value => this.OnCapabilitySwitchChanged(capability, value))"
Disabled="@this.IsEnterpriseConfiguration"
Color="Color.Primary" />
<MudButton Variant="Variant.Outlined"
Color="Color.Default"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.RestartAlt"
Disabled="@(!this.HasCapabilityOverride(capability))"
Disabled="@(this.IsEnterpriseConfiguration || !this.HasCapabilityOverride(capability))"
OnClick="@(() => this.ResetCapabilityOverride(capability))">
@T("Reset")
</MudButton>
@ -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)
{
<MudSelectItem Value="@mode">
@ -229,7 +241,7 @@
<MudJustifiedText Typo="Typo.body2" Class="mb-4">
@string.Format(T("The current model uses the {0}."), this.GetCurrentModelApiLabel())
</MudJustifiedText>
<MudTextField T="string" Label=@T("Additional API parameters") Variant="Variant.Outlined" Lines="4" AutoGrow="true" MaxLines="10" HelperText=@T("""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.""") Placeholder="@GetPlaceholderExpertSettings" @bind-Value="@this.AdditionalJsonApiParameters" Immediate="true" Validation="@this.ValidateAdditionalJsonApiParameters" OnBlur="@this.OnInputChangeExpertSettings"/>
<MudTextField T="string" Label=@T("Additional API parameters") Variant="Variant.Outlined" Lines="4" AutoGrow="true" MaxLines="10" HelperText=@T("""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.""") Placeholder="@GetPlaceholderExpertSettings" @bind-Value="@this.AdditionalJsonApiParameters" Immediate="true" Disabled="@this.IsEnterpriseConfiguration" Validation="@this.ValidateAdditionalJsonApiParameters" OnBlur="@this.OnInputChangeExpertSettings"/>
</MudCollapse>
</MudStack>
</MudForm>

View File

@ -90,6 +90,13 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
/// </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 <see cref="Settings.Provider.IsEnterpriseConfiguration"/>.
/// </summary>
[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

View File

@ -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:<base64-encoded encrypted data>",
--
-- -- 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"] = "<the model ID>",
-- ["DisplayName"] = "<user-friendly name of the model>",

View File

@ -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"

View File

@ -21,6 +21,7 @@ namespace AIStudio.Settings;
/// <param name="IsSelfHosted">Whether the provider is self-hosted.</param>
/// <param name="Hostname">The hostname of the provider. Useful for self-hosted providers.</param>
/// <param name="Model">The LLM model to use for chat.</param>
/// <param name="AllowUserProvidedAPIKey">When set by a configuration plugin, the user may set their own API key for this otherwise locked, enterprise-managed provider.</param>
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<Provider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<Provider>();
@ -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<bool>(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<string>(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<string>(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<string>(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})");

View File

@ -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);

View File

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