diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 8a1ca4e70..17db92a21 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -5647,6 +5647,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T4209882371"] = "1 to
-- {0} tools selected
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T807707919"] = "{0} tools selected"
+-- Your organization has set up AI providers for you. To use them, AI Studio needs your personal API key. Please enter it below.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T1248803223"] = "Your organization has set up AI providers for you. To use them, AI Studio needs your personal API key. Please enter it below."
+
+-- AI Studio checked your API key and stored it. You can now use the AI providers of your organization.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2043185673"] = "AI Studio checked your API key and stored it. You can now use the AI providers of your organization."
+
+-- AI Studio stored your API key but could not check it. If the AI providers of your organization do not work, please correct the key in the settings.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2353931931"] = "AI Studio stored your API key but could not check it. If the AI providers of your organization do not work, please correct the key in the settings."
+
+-- Your API key is missing
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2932314363"] = "Your API key is missing"
+
+-- API key
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T3924805356"] = "API key"
+
+-- Check and save API key
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T4109992116"] = "Check and save API key"
+
+-- The provider did not accept this API key. Please check the key and try again.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T4278329668"] = "The provider did not accept this API key. Please check the key and try again."
+
+-- AI Studio could not store the API key for these providers: {0}
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T614782928"] = "AI Studio could not store the API key for these providers: {0}"
+
-- You'll interact with the AI systems using your voice. To achieve this, we want to integrate voice input (speech-to-text) and output (text-to-speech). However, later on, it should also have a natural conversation flow, i.e., seamless conversation.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1015366320"] = "You'll interact with the AI systems using your voice. To achieve this, we want to integrate voice input (speech-to-text) and output (text-to-speech). However, later on, it should also have a natural conversation flow, i.e., seamless conversation."
diff --git a/app/MindWork AI Studio/Components/UserAPIKeyPrompt.razor b/app/MindWork AI Studio/Components/UserAPIKeyPrompt.razor
new file mode 100644
index 000000000..a52573e00
--- /dev/null
+++ b/app/MindWork AI Studio/Components/UserAPIKeyPrompt.razor
@@ -0,0 +1,33 @@
+@inherits MSGComponentBase
+
+
+ @if (this.Group.Prompt is not null)
+ {
+
+ }
+ else
+ {
+
+ @T("Your organization has set up AI providers for you. To use them, AI Studio needs your personal API key. Please enter it below.")
+
+ }
+
+
+
+ @if (!string.IsNullOrWhiteSpace(this.issue))
+ {
+
+ @this.issue
+
+ }
+
+
+
+ @T("Check and save API key")
+
+ @if (this.isBusy)
+ {
+
+ }
+
+
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Components/UserAPIKeyPrompt.razor.cs b/app/MindWork AI Studio/Components/UserAPIKeyPrompt.razor.cs
new file mode 100644
index 000000000..6a3392453
--- /dev/null
+++ b/app/MindWork AI Studio/Components/UserAPIKeyPrompt.razor.cs
@@ -0,0 +1,86 @@
+using AIStudio.Tools.Services;
+using AIStudio.Tools.UserAPIKeys;
+
+using Microsoft.AspNetCore.Components;
+
+namespace AIStudio.Components;
+
+///
+/// The panel of the home page which asks the user for the API key of the providers that one configuration
+/// plugin marked with "PromptForUserAPIKey". The home page shows one such panel per plugin, as long as one
+/// of its providers has no API key.
+///
+public partial class UserAPIKeyPrompt : MSGComponentBase
+{
+ ///
+ /// The providers of the configuration plugin which wait for the API key of the user.
+ ///
+ [Parameter, EditorRequired]
+ public PendingUserAPIKeyGroup Group { get; set; } = null!;
+
+ ///
+ /// Whether the panel is expanded.
+ ///
+ [Parameter]
+ public bool IsExpanded { get; set; }
+
+ ///
+ /// Called when the user expands or collapses the panel.
+ ///
+ [Parameter]
+ public Func ExpandedChanged { get; set; } = _ => Task.CompletedTask;
+
+ [Inject]
+ private RustService RustService { get; init; } = null!;
+
+ [Inject]
+ private ILogger Logger { get; init; } = null!;
+
+ private string apiKey = string.Empty;
+ private string issue = string.Empty;
+ private bool isBusy;
+
+ private string Title => string.IsNullOrWhiteSpace(this.Group.Prompt?.Title) ? T("Your API key is missing") : this.Group.Prompt.Title;
+
+ private async Task StoreAPIKeyAsync()
+ {
+ var enteredAPIKey = this.apiKey.Trim();
+ if (this.isBusy || string.IsNullOrWhiteSpace(enteredAPIKey))
+ return;
+
+ this.isBusy = true;
+ this.issue = string.Empty;
+ try
+ {
+ var result = await PendingUserAPIKeys.StoreAsync(this.Group, enteredAPIKey, this.RustService, this.Logger);
+ if (result.Check is UserAPIKeyCheckOutcome.REJECTED)
+ {
+ this.issue = T("The provider did not accept this API key. Please check the key and try again.");
+ return;
+ }
+
+ //
+ // Once the key is stored, the home page builds its panels anew, and this panel loses what it shows.
+ // Hence, a failure to store the key goes into a message of its own instead of into this panel:
+ //
+ if (result.FailedProviderNames.Count > 0)
+ await this.MessageBus.SendError(new DataErrorMessage(Icons.Material.Filled.Key, string.Format(T("AI Studio could not store the API key for these providers: {0}"), string.Join(", ", result.FailedProviderNames))));
+
+ if (result.FailedProviderNames.Count == this.Group.Targets.Count)
+ return;
+
+ this.apiKey = string.Empty;
+ if (result.Check is UserAPIKeyCheckOutcome.ACCEPTED)
+ await this.MessageBus.SendSuccess(new DataSuccessMessage(Icons.Material.Filled.Key, T("AI Studio checked your API key and stored it. You can now use the AI providers of your organization.")));
+ else
+ await this.MessageBus.SendWarning(new DataWarningMessage(Icons.Material.Filled.Key, T("AI Studio stored your API key but could not check it. If the AI providers of your organization do not work, please correct the key in the settings.")));
+
+ // Tell everyone that the providers are usable now. The home page removes this panel once no provider waits any longer:
+ await this.SendMessage(Event.CONFIGURATION_CHANGED);
+ }
+ finally
+ {
+ this.isBusy = false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Pages/Home.razor b/app/MindWork AI Studio/Pages/Home.razor
index 9843cd8a1..30892ad95 100644
--- a/app/MindWork AI Studio/Pages/Home.razor
+++ b/app/MindWork AI Studio/Pages/Home.razor
@@ -12,6 +12,11 @@
{
+ @foreach (var group in this.pendingUserAPIKeyGroups)
+ {
+
+ }
+
@if (this.SettingsManager.ConfigurationData.App.ShowIntroduction)
{
diff --git a/app/MindWork AI Studio/Pages/Home.razor.cs b/app/MindWork AI Studio/Pages/Home.razor.cs
index 6031cc81e..e7547e295 100644
--- a/app/MindWork AI Studio/Pages/Home.razor.cs
+++ b/app/MindWork AI Studio/Pages/Home.razor.cs
@@ -1,6 +1,8 @@
using AIStudio.Components;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
+using AIStudio.Tools.Services;
+using AIStudio.Tools.UserAPIKeys;
using Microsoft.AspNetCore.Components;
@@ -15,13 +17,21 @@ public partial class Home : MSGComponentBase
[Inject]
private NavigationManager NavigationManager { get; init; } = null!;
+
+ [Inject]
+ private RustService RustService { get; init; } = null!;
private string LastChangeContent { get; set; } = string.Empty;
private TextItem[] itemsAdvantages = [];
private List introductions = [];
-
+
+ private IReadOnlyList pendingUserAPIKeyGroups = [];
+ private Task initialReadOfPendingUserAPIKeys = Task.CompletedTask;
+ private bool hasReadPendingUserAPIKeys;
+ private int pendingUserAPIKeysGeneration;
+
private string expandedPanelId = string.Empty;
private int expansionPanelsRenderKey;
@@ -43,19 +53,37 @@ public partial class Home : MSGComponentBase
// Read the last change content asynchronously
// without blocking the UI thread:
this.ReadLastChangeAsync().Observe($"{nameof(Home)}: reading the last change");
+
+ // Reading the stored API keys takes a moment, so the page renders the other panels first:
+ this.initialReadOfPendingUserAPIKeys = this.RefreshPendingUserAPIKeysAsync();
+ await this.initialReadOfPendingUserAPIKeys;
}
- protected override Task OnAfterRenderAsync(bool firstRender)
+ protected override async Task OnAfterRenderAsync(bool firstRender)
{
+ await base.OnAfterRenderAsync(firstRender);
if (this.SettingsManager.StartupStartPageRedirectHandled || !this.SettingsManager.HasCompletedInitialSettingsLoad)
- return base.OnAfterRenderAsync(firstRender);
+ return;
this.SettingsManager.StartupStartPageRedirectHandled = true;
var startPageRoute = this.SettingsManager.ConfigurationData.App.StartPage.ToRoute();
- if (!string.IsNullOrWhiteSpace(startPageRoute))
- this.NavigationManager.NavigateTo(startPageRoute, replace: true);
+ if (string.IsNullOrWhiteSpace(startPageRoute))
+ return;
- return base.OnAfterRenderAsync(firstRender);
+ //
+ // The home page asks for missing API keys of the organization's providers. Stay here while
+ // one is missing, so the user sees that request no matter the chosen start page. The page
+ // reads the keys while it initializes; when the settings were not loaded by then, it reads
+ // them now:
+ //
+ await this.initialReadOfPendingUserAPIKeys;
+ if (!this.hasReadPendingUserAPIKeys)
+ await this.RefreshPendingUserAPIKeysAsync();
+
+ if (this.pendingUserAPIKeyGroups.Count > 0)
+ return;
+
+ this.NavigationManager.NavigateTo(startPageRoute, replace: true);
}
private void InitializeAdvantagesItems()
@@ -87,12 +115,14 @@ public partial class Home : MSGComponentBase
case Event.PLUGINS_RELOADED:
this.InitializeAdvantagesItems();
this.RefreshIntroductionPanels();
+ await this.RefreshPendingUserAPIKeysAsync();
this.EnsureDefaultExpandedPanel();
await this.InvokeAsync(this.StateHasChanged);
break;
case Event.CONFIGURATION_CHANGED:
this.RefreshIntroductionPanels();
+ await this.RefreshPendingUserAPIKeysAsync();
this.EnsureDefaultExpandedPanel();
await this.InvokeAsync(this.StateHasChanged);
break;
@@ -106,7 +136,34 @@ public partial class Home : MSGComponentBase
this.introductions = PluginFactory.GetIntroductions().ToList();
}
+ ///
+ /// Reads which providers of the organization wait for the API key of the user. When the configuration
+ /// plugins of these providers change, the page opens the first panel which asks for a key.
+ ///
+ private async Task RefreshPendingUserAPIKeysAsync()
+ {
+ // Which providers exist is unknown until the settings are loaded:
+ if (!this.SettingsManager.HasCompletedInitialSettingsLoad)
+ return;
+
+ // Several events may ask for a refresh at once; only the latest answer counts:
+ var generation = ++this.pendingUserAPIKeysGeneration;
+ var groups = await PendingUserAPIKeys.FindAsync(this.SettingsManager, this.RustService);
+ if (generation != this.pendingUserAPIKeysGeneration)
+ return;
+
+ var hasChanged = !groups.Select(group => group.ConfigPluginId).SequenceEqual(this.pendingUserAPIKeyGroups.Select(group => group.ConfigPluginId));
+ this.pendingUserAPIKeyGroups = groups;
+ this.hasReadPendingUserAPIKeys = true;
+ if (!hasChanged)
+ return;
+
+ this.EnsureDefaultExpandedPanel();
+ await this.InvokeAsync(this.StateHasChanged);
+ }
+
private bool HasVisibleHomePanels =>
+ this.pendingUserAPIKeyGroups.Count > 0 ||
this.SettingsManager.ConfigurationData.App.ShowIntroduction ||
this.introductions.Count > 0 ||
this.SettingsManager.ConfigurationData.App.ShowLastChangelog ||
@@ -115,6 +172,11 @@ public partial class Home : MSGComponentBase
private string GetDefaultExpandedPanelId()
{
+ // A missing API key keeps the providers of the organization from working, so it comes first:
+ var firstPendingUserAPIKeyGroup = this.pendingUserAPIKeyGroups.FirstOrDefault();
+ if (firstPendingUserAPIKeyGroup is not null)
+ return UserAPIKeyPanelId(firstPendingUserAPIKeyGroup);
+
if (this.SettingsManager.ConfigurationData.App.ShowIntroduction)
return PANEL_ID_BUILT_IN_INTRODUCTION;
@@ -154,6 +216,8 @@ public partial class Home : MSGComponentBase
private static string IntroductionPanelId(DataIntroduction introduction) => $"introduction:{introduction.Id}";
+ private static string UserAPIKeyPanelId(PendingUserAPIKeyGroup group) => $"user-api-key:{group.ConfigPluginId}";
+
private async Task ReadLastChangeAsync()
{
var latest = Changelog.LOGS.MaxBy(n => n.Build);
diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua
index 5e1b0cac6..f11b2db28 100644
--- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua
@@ -172,6 +172,12 @@ CONFIG["LLM_PROVIDERS"] = {}
-- -- if this configuration is later withdrawn.
-- -- ["AllowUserProvidedAPIKey"] = true,
--
+-- -- Optional: ask the user for their API key in a panel on the home page, as long as this
+-- -- provider has none. All providers of this plugin which set this option share one panel
+-- -- and receive the same key, so the user enters it once. Implies "AllowUserProvidedAPIKey".
+-- -- Explain how to obtain the key in CONFIG["USER_API_KEY_PROMPT"], see below.
+-- -- ["PromptForUserAPIKey"] = true,
+--
-- ["Model"] = {
-- ["Id"] = "",
-- ["DisplayName"] = "",
@@ -203,6 +209,10 @@ CONFIG["TRANSCRIPTION_PROVIDERS"] = {}
-- -- above: when both are set, the embedded key is ignored and a warning is logged.
-- -- ["AllowUserProvidedAPIKey"] = true,
--
+-- -- Optional: ask the user for their API key on the home page, as long as this provider
+-- -- has none (see LLM_PROVIDERS example for details). Implies "AllowUserProvidedAPIKey".
+-- -- ["PromptForUserAPIKey"] = true,
+--
-- -- Optional: Hugging Face inference provider. Only relevant for UsedLLMProvider = HUGGINGFACE.
-- -- Hugging Face transcribes audio through some of its inference providers only, so the choice
-- -- is narrower than for chatting. Allowed values are: DEEPINFRA and TOGETHER_AI. The automatic
@@ -249,6 +259,10 @@ CONFIG["EMBEDDING_PROVIDERS"] = {}
-- -- above: when both are set, the embedded key is ignored and a warning is logged.
-- -- ["AllowUserProvidedAPIKey"] = true,
--
+-- -- Optional: ask the user for their API key on the home page, as long as this provider
+-- -- has none (see LLM_PROVIDERS example for details). Implies "AllowUserProvidedAPIKey".
+-- -- ["PromptForUserAPIKey"] = true,
+--
-- -- Optional: Hugging Face inference provider. Only relevant for UsedLLMProvider = HUGGINGFACE.
-- -- Hugging Face serves embeddings through some of its inference providers only, so the choice
-- -- is narrower than for chatting. Allowed values are: DEEPINFRA and TOGETHER_AI. The automatic
@@ -1369,6 +1383,23 @@ CONFIG["INTRODUCTIONS"] = {}
-- ]===]
-- }
+-- What AI Studio tells users in the panel on the home page which asks for their API key. It asks
+-- as long as a provider of this plugin which sets "PromptForUserAPIKey" has no API key. AI Studio
+-- checks the key the user enters with the first of these providers which can list its models,
+-- and then stores it for all of them. A key the provider refuses is not stored. When no provider
+-- can check the key, or the provider is out of reach, AI Studio stores the key with a warning.
+-- Markdown is required, Title is optional. Without this table, AI Studio shows a generic request.
+-- CONFIG["USER_API_KEY_PROMPT"] = {
+-- ["Title"] = "Please add your API key",
+-- ["Markdown"] = [===[
+-- To use the AI models of your organization, you need a personal API key:
+--
+-- 1. Open [Open WebUI](https://example.org) and sign in.
+-- 2. Click your profile picture, then "Settings" and "Account".
+-- 3. Create an API key, copy it, and paste it below.
+-- ]===]
+-- }
+
-- Mandatory infos that users must explicitly accept before using AI Studio:
-- AI Studio asks users again when Version, Title, or Markdown change.
-- Changing Version additionally allows the UI to communicate that a new version is available.
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 d1b3757c0..1c19cac12 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
@@ -5649,6 +5649,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T4209882371"] = "1 We
-- {0} tools selected
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T807707919"] = "{0} Werkzeuge ausgewählt"
+-- Your organization has set up AI providers for you. To use them, AI Studio needs your personal API key. Please enter it below.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T1248803223"] = "Ihre Organisation hat KI-Anbieter für Sie eingerichtet. Damit Sie diese nutzen können, benötigt AI Studio Ihren persönlichen API-Schlüssel. Bitte geben Sie ihn unten ein."
+
+-- AI Studio checked your API key and stored it. You can now use the AI providers of your organization.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2043185673"] = "AI Studio hat Ihren API-Schlüssel geprüft und gespeichert. Sie können jetzt die KI-Anbieter Ihrer Organisation nutzen."
+
+-- AI Studio stored your API key but could not check it. If the AI providers of your organization do not work, please correct the key in the settings.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2353931931"] = "AI Studio hat Ihren API-Schlüssel gespeichert, konnte ihn aber nicht prüfen. Falls die KI-Anbieter Ihrer Organisation nicht funktionieren, korrigieren Sie den Schlüssel bitte in den Einstellungen."
+
+-- Your API key is missing
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2932314363"] = "Ihr API-Schlüssel fehlt"
+
+-- API key
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T3924805356"] = "API-Schlüssel"
+
+-- Check and save API key
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T4109992116"] = "API-Schlüssel prüfen und speichern"
+
+-- The provider did not accept this API key. Please check the key and try again.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T4278329668"] = "Der Anbieter hat diesen API-Schlüssel nicht akzeptiert. Bitte prüfen Sie den Schlüssel und versuchen Sie es erneut."
+
+-- AI Studio could not store the API key for these providers: {0}
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T614782928"] = "AI Studio konnte den API-Schlüssel für folgende Anbieter nicht speichern: {0}"
+
-- You'll interact with the AI systems using your voice. To achieve this, we want to integrate voice input (speech-to-text) and output (text-to-speech). However, later on, it should also have a natural conversation flow, i.e., seamless conversation.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1015366320"] = "Sie werden mit den KI-Systemen über Ihre Stimme interagieren. Dafür möchten wir Spracheingabe (Sprache-zu-Text) und Sprachausgabe (Text-zu-Sprache) integrieren. Später soll außerdem ein natürlicher Gesprächsfluss möglich sein, also eine nahtlose Unterhaltung."
diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
index 06b287f03..d85a59ec5 100644
--- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
@@ -5649,6 +5649,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T4209882371"] = "1 to
-- {0} tools selected
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T807707919"] = "{0} tools selected"
+-- Your organization has set up AI providers for you. To use them, AI Studio needs your personal API key. Please enter it below.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T1248803223"] = "Your organization has set up AI providers for you. To use them, AI Studio needs your personal API key. Please enter it below."
+
+-- AI Studio checked your API key and stored it. You can now use the AI providers of your organization.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2043185673"] = "AI Studio checked your API key and stored it. You can now use the AI providers of your organization."
+
+-- AI Studio stored your API key but could not check it. If the AI providers of your organization do not work, please correct the key in the settings.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2353931931"] = "AI Studio stored your API key but could not check it. If the AI providers of your organization do not work, please correct the key in the settings."
+
+-- Your API key is missing
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T2932314363"] = "Your API key is missing"
+
+-- API key
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T3924805356"] = "API key"
+
+-- Check and save API key
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T4109992116"] = "Check and save API key"
+
+-- The provider did not accept this API key. Please check the key and try again.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T4278329668"] = "The provider did not accept this API key. Please check the key and try again."
+
+-- AI Studio could not store the API key for these providers: {0}
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::USERAPIKEYPROMPT::T614782928"] = "AI Studio could not store the API key for these providers: {0}"
+
-- You'll interact with the AI systems using your voice. To achieve this, we want to integrate voice input (speech-to-text) and output (text-to-speech). However, later on, it should also have a natural conversation flow, i.e., seamless conversation.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1015366320"] = "You'll interact with the AI systems using your voice. To achieve this, we want to integrate voice input (speech-to-text) and output (text-to-speech). However, later on, it should also have a natural conversation flow, i.e., seamless conversation."
diff --git a/app/MindWork AI Studio/Settings/DataModel/DataUserAPIKeyPrompt.cs b/app/MindWork AI Studio/Settings/DataModel/DataUserAPIKeyPrompt.cs
new file mode 100644
index 000000000..6e64507a3
--- /dev/null
+++ b/app/MindWork AI Studio/Settings/DataModel/DataUserAPIKeyPrompt.cs
@@ -0,0 +1,63 @@
+using AIStudio.Tools.PluginSystem;
+
+using Lua;
+
+namespace AIStudio.Settings.DataModel;
+
+///
+/// What a configuration plugin tells the user while AI Studio asks for the API key of the providers
+/// which that plugin marked with "PromptForUserAPIKey".
+///
+public sealed record DataUserAPIKeyPrompt : ILivePluginContent
+{
+ private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger();
+
+ ///
+ /// The stable ID of the prompt. A configuration plugin has at most one, so this is the plugin's ID.
+ ///
+ public string Id { get; private init; } = string.Empty;
+
+ ///
+ /// The ID of the enterprise configuration plugin that provides this prompt.
+ ///
+ public Guid EnterpriseConfigurationPluginId { get; private init; } = Guid.Empty;
+
+ ///
+ /// The optional title shown to the user. When empty, AI Studio shows a title of its own.
+ ///
+ public string Title { get; private init; } = string.Empty;
+
+ ///
+ /// The Markdown instructions which explain how to obtain the API key.
+ ///
+ public string Markdown { get; private init; } = string.Empty;
+
+ public static bool TryParseConfiguration(LuaTable table, Guid configPluginId, out DataUserAPIKeyPrompt prompt)
+ {
+ prompt = new DataUserAPIKeyPrompt();
+ if (!table.TryGetValue("Markdown", out var markdownValue) || !markdownValue.TryRead(out var markdown) || string.IsNullOrWhiteSpace(markdown))
+ {
+ LOG.LogWarning("The configured user API key prompt does not contain a valid Markdown field (config plugin id: {ConfigPluginId}).", configPluginId);
+ return false;
+ }
+
+ var title = string.Empty;
+ if (table.TryGetValue("Title", out var titleValue))
+ {
+ if (titleValue.TryRead(out var titleText))
+ title = titleText;
+ else
+ LOG.LogWarning("The configured user API key prompt does not contain a valid Title field. Falling back to the built-in title (config plugin id: {ConfigPluginId}).", configPluginId);
+ }
+
+ prompt = new DataUserAPIKeyPrompt
+ {
+ Id = configPluginId.ToString(),
+ Title = title.Trim(),
+ Markdown = AIStudio.Tools.Markdown.RemoveSharedIndentation(markdown),
+ EnterpriseConfigurationPluginId = configPluginId,
+ };
+
+ return true;
+ }
+}
diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs
index cc822ca01..b59b5f4a4 100644
--- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs
+++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs
@@ -29,7 +29,8 @@ public sealed record EmbeddingProvider(
int TokenLimit = 0,
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "",
- HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
+ HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE,
+ bool PromptForUserAPIKey = false) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
public const int DEFAULT_TOKEN_LIMIT = 8192;
public const int DEFAULT_EMBEDDING_BATCH_SIZE = 1;
@@ -128,9 +129,7 @@ public sealed record EmbeddingProvider(
embeddingBatchSize = DEFAULT_EMBEDDING_BATCH_SIZE;
}
- var allowUserProvidedApiKey = false;
- if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead(out var allowUserProvidedApiKeyBool))
- allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
+ var apiKeyOptions = UserProvidedAPIKeyOptions.Read(table);
var hfInferenceProvider = HFInferenceProvider.NONE;
if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead(out var hfInferenceProviderText))
@@ -166,7 +165,8 @@ public sealed record EmbeddingProvider(
TokenizerPath = tokenizerPath,
EmbeddingBatchSize = embeddingBatchSize,
TokenLimit = tokenLimit,
- AllowUserProvidedAPIKey = allowUserProvidedApiKey,
+ AllowUserProvidedAPIKey = apiKeyOptions.AllowUserProvidedAPIKey,
+ PromptForUserAPIKey = apiKeyOptions.PromptForUserAPIKey,
CustomIconDataUrl = customIconDataUrl,
HFInferenceProvider = hfInferenceProvider,
};
@@ -174,7 +174,7 @@ public sealed record EmbeddingProvider(
// Handle an encrypted API key if present. When the user manages their own key for this
// embedding 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 (apiKeyOptions.AllowUserProvidedAPIKey)
{
if (table.TryGetValue("APIKey", out var ignoredApiKeyValue) && ignoredApiKeyValue.TryRead(out var ignoredApiKeyText) && !string.IsNullOrWhiteSpace(ignoredApiKeyText))
LOGGER.LogWarning($"The configured embedding 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})");
diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs
index 56630eab9..3498de8f2 100644
--- a/app/MindWork AI Studio/Settings/Provider.cs
+++ b/app/MindWork AI Studio/Settings/Provider.cs
@@ -24,6 +24,7 @@ namespace AIStudio.Settings;
/// 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.
/// The validated custom SVG icon supplied by a configuration plugin.
+/// When set by a configuration plugin, AI Studio asks the user for the API key of this provider on the home page as long as none is stored.
public sealed record Provider(
uint Num,
string Id,
@@ -40,7 +41,8 @@ public sealed record Provider(
[property: JsonConverter(typeof(DataDirectoryRelativePathConverter))] string TokenizerPath = "",
ProviderCapabilityOverrides? CapabilityOverrides = null,
bool AllowUserProvidedAPIKey = false,
- string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
+ string CustomIconDataUrl = "",
+ bool PromptForUserAPIKey = false) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger();
@@ -159,9 +161,7 @@ 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;
+ var apiKeyOptions = UserProvidedAPIKeyOptions.Read(table);
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
@@ -188,14 +188,15 @@ public sealed record Provider(
AdditionalJsonApiParameters = additionalJsonApiParameters,
TokenizerPath = tokenizerPath,
CapabilityOverrides = capabilityOverrides,
- AllowUserProvidedAPIKey = allowUserProvidedApiKey,
+ AllowUserProvidedAPIKey = apiKeyOptions.AllowUserProvidedAPIKey,
+ PromptForUserAPIKey = apiKeyOptions.PromptForUserAPIKey,
CustomIconDataUrl = customIconDataUrl,
};
// 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 (apiKeyOptions.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})");
diff --git a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs
index c93febd93..f5dec6b84 100644
--- a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs
+++ b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs
@@ -24,7 +24,8 @@ public sealed record TranscriptionProvider(
Host Host = Host.NONE,
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "",
- HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
+ HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE,
+ bool PromptForUserAPIKey = false) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger();
@@ -101,9 +102,7 @@ public sealed record TranscriptionProvider(
return false;
}
- var allowUserProvidedApiKey = false;
- if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead(out var allowUserProvidedApiKeyBool))
- allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
+ var apiKeyOptions = UserProvidedAPIKeyOptions.Read(table);
var hfInferenceProvider = HFInferenceProvider.NONE;
if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead(out var hfInferenceProviderText))
@@ -136,7 +135,8 @@ public sealed record TranscriptionProvider(
EnterpriseConfigurationPluginId = configPluginId,
Hostname = hostname,
Host = host,
- AllowUserProvidedAPIKey = allowUserProvidedApiKey,
+ AllowUserProvidedAPIKey = apiKeyOptions.AllowUserProvidedAPIKey,
+ PromptForUserAPIKey = apiKeyOptions.PromptForUserAPIKey,
CustomIconDataUrl = customIconDataUrl,
HFInferenceProvider = hfInferenceProvider,
};
@@ -144,7 +144,7 @@ public sealed record TranscriptionProvider(
// Handle an encrypted API key if present. When the user manages their own key for this
// transcription 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 (apiKeyOptions.AllowUserProvidedAPIKey)
{
if (table.TryGetValue("APIKey", out var ignoredApiKeyValue) && ignoredApiKeyValue.TryRead(out var ignoredApiKeyText) && !string.IsNullOrWhiteSpace(ignoredApiKeyText))
LOGGER.LogWarning($"The configured transcription 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})");
diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs
index 6c1d57b40..a3e5dca60 100644
--- a/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs
+++ b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs
@@ -76,19 +76,20 @@ public static class ConfigurationSnippetImport
return true;
//
- // A user-provided API key is what an organization grants for a provider it manages. A
- // provider of the user's own has nothing else, so the flag carries no meaning here:
+ // A user-provided API key, and asking the user for it, is what an organization grants for a
+ // provider it manages. A provider of the user's own has nothing else, so neither option carries
+ // any meaning here:
//
case Settings.Provider provider:
- result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []);
+ result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false, PromptForUserAPIKey = false }, ReadAPIKey(table, hints), hints, []);
return true;
case EmbeddingProvider provider:
- result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []);
+ result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false, PromptForUserAPIKey = false }, ReadAPIKey(table, hints), hints, []);
return true;
case TranscriptionProvider provider:
- result = new(MakeLocal(provider) with { AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []);
+ result = new(MakeLocal(provider) with { AllowUserProvidedAPIKey = false, PromptForUserAPIKey = false }, ReadAPIKey(table, hints), hints, []);
return true;
//
diff --git a/app/MindWork AI Studio/Tools/PluginSystem/IUserProvidedAPIKey.cs b/app/MindWork AI Studio/Tools/PluginSystem/IUserProvidedAPIKey.cs
index db0cb0543..8cdd23ca9 100644
--- a/app/MindWork AI Studio/Tools/PluginSystem/IUserProvidedAPIKey.cs
+++ b/app/MindWork AI Studio/Tools/PluginSystem/IUserProvidedAPIKey.cs
@@ -3,7 +3,8 @@ namespace AIStudio.Tools.PluginSystem;
///
/// Represents a configuration object whose API key is managed by the user, although the object
/// itself is managed by a configuration plugin. Implemented by all provider kinds which support
-/// the "AllowUserProvidedAPIKey" option, i.e., LLM, embedding, and transcription providers.
+/// the options "AllowUserProvidedAPIKey" and "PromptForUserAPIKey", i.e., LLM, embedding, and
+/// transcription providers. UserProvidedAPIKeyOptions reads both options from a plugin.
///
public interface IUserProvidedAPIKey
{
@@ -12,4 +13,10 @@ public interface IUserProvidedAPIKey
/// locked, enterprise-managed object.
///
public bool AllowUserProvidedAPIKey { get; }
+
+ ///
+ /// When set by a configuration plugin, AI Studio asks the user for the API key of this object
+ /// on the home page as long as none is stored. Implies AllowUserProvidedAPIKey.
+ ///
+ public bool PromptForUserAPIKey { get; }
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs
index b0ee9699d..4443845a0 100644
--- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs
+++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs
@@ -18,6 +18,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
private List mandatoryInfos = [];
private List introductions = [];
private List mailboxProviders = [];
+ private List userAPIKeyPrompts = [];
///
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
@@ -42,6 +43,12 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
///
public IReadOnlyList MailboxProviders => this.mailboxProviders;
+ ///
+ /// What this configuration plugin tells the user while AI Studio asks for the API key of its providers;
+ /// at most one entry. The prompt is live plugin content and is not persisted to ConfigurationData.
+ ///
+ public IReadOnlyList UserAPIKeyPrompts => this.userAPIKeyPrompts;
+
///
/// True/false when explicitly configured in the plugin, otherwise null.
///
@@ -204,6 +211,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
this.mandatoryInfos.Clear();
this.introductions.Clear();
this.mailboxProviders.Clear();
+ this.userAPIKeyPrompts.Clear();
// Ensure that the main CONFIG table exists and is a valid Lua table:
if (!this.State.Environment["CONFIG"].TryRead(out var mainTable))
@@ -400,6 +408,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Handle configured mail servers:
this.TryReadMailboxProviders(mainTable);
+
+ // Handle the configured prompt for the API key of the user:
+ this.TryReadUserAPIKeyPrompt(mainTable);
// Config: preselected provider?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
@@ -872,4 +883,13 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
LOG.LogWarning("The table 'MAILBOX_PROVIDERS' entry at index {Index} does not contain a valid mail server (config plugin id: {ConfigPluginId}).", i, this.Id);
}
}
+
+ private void TryReadUserAPIKeyPrompt(LuaTable mainTable)
+ {
+ if (!mainTable.TryGetValue("USER_API_KEY_PROMPT", out var promptValue) || !promptValue.TryRead(out var promptTable))
+ return;
+
+ if (DataUserAPIKeyPrompt.TryParseConfiguration(promptTable, this.Id, out var prompt))
+ this.userAPIKeyPrompts.Add(prompt);
+ }
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs
index 46f82edec..48e1360c9 100644
--- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs
+++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs
@@ -449,6 +449,15 @@ public static partial class PluginFactory
.ToList();
}
+ ///
+ /// Collects what the running configuration plugins tell the user while AI Studio asks for an API key.
+ ///
+ /// The prompts of all configuration plugins, at most one per plugin.
+ public static IReadOnlyList GetUserAPIKeyPrompts()
+ {
+ return ResolveLivePluginContent("user API key prompt", plugin => plugin.UserAPIKeyPrompts).ToList();
+ }
+
///
/// Collects the mail servers which the running configuration plugins offer for new mailboxes.
///
diff --git a/app/MindWork AI Studio/Tools/PluginSystem/UserProvidedAPIKeyOptions.cs b/app/MindWork AI Studio/Tools/PluginSystem/UserProvidedAPIKeyOptions.cs
new file mode 100644
index 000000000..b4f85031a
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/PluginSystem/UserProvidedAPIKeyOptions.cs
@@ -0,0 +1,31 @@
+using Lua;
+
+namespace AIStudio.Tools.PluginSystem;
+
+///
+/// What a configuration plugin allows for the API key of a provider it manages: whether the user may set
+/// their own key, and whether AI Studio asks the user for it on the home page.
+///
+/// The user may set their own API key for the otherwise locked provider.
+/// AI Studio asks the user for the API key on the home page as long as none is stored.
+public readonly record struct UserProvidedAPIKeyOptions(bool AllowUserProvidedAPIKey, bool PromptForUserAPIKey)
+{
+ ///
+ /// Reads both options from the Lua table of a provider. An option which is missing or not a boolean counts as false.
+ ///
+ ///
+ /// Asking the user for the key only makes sense when the user may set it. Therefore, PromptForUserAPIKey
+ /// implies AllowUserProvidedAPIKey, and a configuration needs to name only the one option. The implied
+ /// option also keeps the key of the user in the OS keyring when the configuration is withdrawn.
+ ///
+ /// The Lua table of the provider.
+ /// The options of the provider.
+ public static UserProvidedAPIKeyOptions Read(LuaTable table)
+ {
+ var promptForUserAPIKey = ReadOption(table, "PromptForUserAPIKey");
+ var allowUserProvidedAPIKey = promptForUserAPIKey || ReadOption(table, "AllowUserProvidedAPIKey");
+ return new UserProvidedAPIKeyOptions(allowUserProvidedAPIKey, promptForUserAPIKey);
+ }
+
+ private static bool ReadOption(LuaTable table, string name) => table.TryGetValue(name, out var value) && value.TryRead(out var isSet) && isSet;
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs
index 90e8606b0..776b28806 100644
--- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs
+++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs
@@ -306,10 +306,23 @@ public sealed class EnterpriseEnvironmentService(ILogger
+ /// Whether the secret of a configuration object came with the configuration of the organization, so that it
+ /// goes when the organization changes its encryption secret.
+ ///
+ ///
+ /// A provider which allows the API key of the user holds the key the user entered, not one of the organization.
+ /// The new encryption secret does not concern that key, and the configuration brings none to replace it.
+ /// Hence, the key stays, just as it stays when the organization withdraws the provider.
+ ///
+ /// The configuration object which holds a secret.
+ /// True, when the secret has to go along with the old encryption secret.
+ internal static bool HoldsSecretOfTheOrganization(IConfigurationObject configObject) => configObject.IsEnterpriseConfiguration && configObject.EnterpriseConfigurationPluginId != Guid.Empty && configObject is not IUserProvidedAPIKey { AllowUserProvidedAPIKey: true };
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeyGroup.cs b/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeyGroup.cs
new file mode 100644
index 000000000..a37ec0d37
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeyGroup.cs
@@ -0,0 +1,11 @@
+using AIStudio.Settings.DataModel;
+
+namespace AIStudio.Tools.UserAPIKeys;
+
+///
+/// The providers of one configuration plugin which wait for the API key of the user. They share one key.
+///
+/// The ID of the configuration plugin.
+/// What the configuration plugin tells the user about obtaining the key, if anything.
+/// The providers which receive the key.
+public sealed record PendingUserAPIKeyGroup(Guid ConfigPluginId, DataUserAPIKeyPrompt? Prompt, IReadOnlyList Targets);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeyTarget.cs b/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeyTarget.cs
new file mode 100644
index 000000000..9bea33aaa
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeyTarget.cs
@@ -0,0 +1,12 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Tools.UserAPIKeys;
+
+///
+/// One provider of a configuration plugin which waits for the API key of the user.
+///
+/// The name of the provider as the user knows it.
+/// Where the API key of the provider is stored.
+/// The secret store of the provider's kind, which also tells which models the provider offers.
+/// Creates the provider, e.g., to check a key. Called only on demand, since every provider brings an HTTP client of its own.
+public sealed record PendingUserAPIKeyTarget(string Name, ISecretId SecretId, SecretStoreType StoreType, Func CreateProvider);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeys.cs b/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeys.cs
new file mode 100644
index 000000000..c5b602665
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/UserAPIKeys/PendingUserAPIKeys.cs
@@ -0,0 +1,136 @@
+using AIStudio.Provider;
+using AIStudio.Settings;
+using AIStudio.Tools.PluginSystem;
+using AIStudio.Tools.Services;
+
+namespace AIStudio.Tools.UserAPIKeys;
+
+///
+/// Finds the providers which a configuration plugin marked with "PromptForUserAPIKey" and which have no API key yet,
+/// and stores the API key the user enters for them.
+///
+public static class PendingUserAPIKeys
+{
+ ///
+ /// Finds all providers which still wait for the API key of the user, grouped by their configuration plugin.
+ ///
+ /// The settings manager, which knows the configured providers.
+ /// The Rust service, which knows the stored API keys.
+ /// One group per configuration plugin with at least one waiting provider.
+ public static async Task> FindAsync(SettingsManager settingsManager, RustService rustService)
+ {
+ var candidates = new List<(Guid ConfigPluginId, PendingUserAPIKeyTarget Target)>();
+ foreach (var provider in settingsManager.GetAllProviders().Where(provider => IsPrompting(provider)))
+ candidates.Add((provider.EnterpriseConfigurationPluginId, new PendingUserAPIKeyTarget(provider.InstanceName, provider, SecretStoreType.LLM_PROVIDER, () => provider.CreateProvider())));
+
+ foreach (var provider in settingsManager.GetAllEmbeddingProviders().Where(provider => IsPrompting(provider)))
+ candidates.Add((provider.EnterpriseConfigurationPluginId, new PendingUserAPIKeyTarget(provider.Name, provider, SecretStoreType.EMBEDDING_PROVIDER, () => provider.CreateProvider())));
+
+ foreach (var provider in settingsManager.GetAllTranscriptionProviders().Where(provider => IsPrompting(provider)))
+ candidates.Add((provider.EnterpriseConfigurationPluginId, new PendingUserAPIKeyTarget(provider.Name, provider, SecretStoreType.TRANSCRIPTION_PROVIDER, () => provider.CreateProvider())));
+
+ if (candidates.Count == 0)
+ return [];
+
+ var pending = new List<(Guid ConfigPluginId, PendingUserAPIKeyTarget Target)>();
+ foreach (var candidate in candidates)
+ {
+ var storedKey = await rustService.GetAPIKey(candidate.Target.SecretId, candidate.Target.StoreType, isTrying: true);
+ if (!storedKey.Success)
+ pending.Add(candidate);
+ }
+
+ if (pending.Count == 0)
+ return [];
+
+ var prompts = PluginFactory.GetUserAPIKeyPrompts().ToDictionary(prompt => prompt.EnterpriseConfigurationPluginId);
+ return pending
+ .GroupBy(entry => entry.ConfigPluginId)
+ .Select(group => new PendingUserAPIKeyGroup(group.Key, prompts.GetValueOrDefault(group.Key), group.Select(entry => entry.Target).ToList()))
+ .ToList();
+ }
+
+ ///
+ /// Checks the API key the user entered with one of the providers of a group, and stores it for all of them
+ /// unless the provider refused it.
+ ///
+ ///
+ /// The providers of a group share one key, so asking one of them is enough: the first which can list its models.
+ /// When none of them can, or the answer tells nothing about the key, for example because the server is out of
+ /// reach, AI Studio stores the key anyway. Otherwise, an outage would keep the user from finishing the setup.
+ ///
+ /// The providers which receive the key.
+ /// The API key the user entered.
+ /// The Rust service, which stores the key in the OS keyring.
+ /// The logger for refused keys and failures to store one.
+ /// What the provider said about the key, and for which providers storing it failed.
+ public static async Task StoreAsync(PendingUserAPIKeyGroup group, string apiKey, RustService rustService, ILogger logger)
+ {
+ var check = await CheckAsync(group, apiKey, logger);
+ if (check is UserAPIKeyCheckOutcome.REJECTED)
+ return UserAPIKeyStoreResult.REJECTED;
+
+ var failedProviderNames = new List();
+ foreach (var target in group.Targets)
+ {
+ var storeResponse = await rustService.SetAPIKey(target.SecretId, apiKey, target.StoreType);
+ if (storeResponse.Success)
+ continue;
+
+ logger.LogError("Failed to store the API key for the provider '{ProviderName}': {Issue}", target.Name, storeResponse.Issue);
+ failedProviderNames.Add(target.Name);
+ }
+
+ return new UserAPIKeyStoreResult(check, failedProviderNames);
+ }
+
+ ///
+ /// Tells from the models a provider loaded with an API key what the provider says about that key.
+ ///
+ ///
+ /// Only a refused sign-in rejects the key, and only at least one model accepts it. Everything else tells nothing
+ /// about the key: an outage, a rate limit, a used-up quota, or an empty list, which some providers return for a
+ /// kind of model they do not offer, whatever the key. One gap remains: a few providers, such as Perplexity, return
+ /// a fixed list without asking their server, so any key passes there.
+ ///
+ /// The models the provider loaded with the key.
+ /// What the provider says about the key.
+ public static UserAPIKeyCheckOutcome ClassifyCheck(ModelLoadResult result) => result switch
+ {
+ { FailureReason: ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY or ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR } => UserAPIKeyCheckOutcome.REJECTED,
+ { Success: true, Models.Count: > 0 } => UserAPIKeyCheckOutcome.ACCEPTED,
+ _ => UserAPIKeyCheckOutcome.NOT_CHECKED
+ };
+
+ private static async Task CheckAsync(PendingUserAPIKeyGroup group, string apiKey, ILogger logger)
+ {
+ //
+ // Every provider instance brings an HTTP client of its own. Hence, we create one only here,
+ // and only until we found a provider which can answer:
+ //
+ foreach (var target in group.Targets)
+ {
+ var provider = target.CreateProvider();
+ if (!provider.HasModelLoadingCapability)
+ continue;
+
+ var outcome = ClassifyCheck(await LoadModelsAsync(provider, target.StoreType, apiKey));
+ if (outcome is UserAPIKeyCheckOutcome.REJECTED)
+ logger.LogWarning("The provider '{ProviderName}' rejected the API key the user entered.", target.Name);
+
+ return outcome;
+ }
+
+ return UserAPIKeyCheckOutcome.NOT_CHECKED;
+ }
+
+ private static Task LoadModelsAsync(IProvider provider, SecretStoreType storeType, string apiKey) => storeType switch
+ {
+ SecretStoreType.LLM_PROVIDER => provider.GetTextModels(apiKey),
+ SecretStoreType.EMBEDDING_PROVIDER => provider.GetEmbeddingModels(apiKey),
+ SecretStoreType.TRANSCRIPTION_PROVIDER => provider.GetTranscriptionModels(apiKey),
+ _ => throw new ArgumentOutOfRangeException(nameof(storeType), storeType, "Only providers wait for the API key of the user.")
+ };
+
+ private static bool IsPrompting(T provider) where T : IConfigurationObject, IUserProvidedAPIKey => provider is { IsEnterpriseConfiguration: true, PromptForUserAPIKey: true };
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/UserAPIKeys/UserAPIKeyCheckOutcome.cs b/app/MindWork AI Studio/Tools/UserAPIKeys/UserAPIKeyCheckOutcome.cs
new file mode 100644
index 000000000..ee98cdc52
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/UserAPIKeys/UserAPIKeyCheckOutcome.cs
@@ -0,0 +1,23 @@
+namespace AIStudio.Tools.UserAPIKeys;
+
+///
+/// What a provider says about an API key the user entered.
+///
+public enum UserAPIKeyCheckOutcome
+{
+ ///
+ /// The provider listed its models with the key, so it accepts the key.
+ ///
+ ACCEPTED,
+
+ ///
+ /// The provider refused the key. AI Studio does not store it.
+ ///
+ REJECTED,
+
+ ///
+ /// Nothing tells whether the provider accepts the key, e.g., because the server was out of reach or the
+ /// provider cannot list its models. AI Studio stores the key anyway.
+ ///
+ NOT_CHECKED
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/UserAPIKeys/UserAPIKeyStoreResult.cs b/app/MindWork AI Studio/Tools/UserAPIKeys/UserAPIKeyStoreResult.cs
new file mode 100644
index 000000000..956fad441
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/UserAPIKeys/UserAPIKeyStoreResult.cs
@@ -0,0 +1,14 @@
+namespace AIStudio.Tools.UserAPIKeys;
+
+///
+/// The result of storing the API key of the user for the providers of one configuration plugin.
+///
+/// What the provider said about the key. A rejected key was not stored at all.
+/// The names of the providers for which the key could not be stored.
+public sealed record UserAPIKeyStoreResult(UserAPIKeyCheckOutcome Check, IReadOnlyList FailedProviderNames)
+{
+ ///
+ /// The result for a key which the provider refused, so that it was not stored.
+ ///
+ public static readonly UserAPIKeyStoreResult REJECTED = new(UserAPIKeyCheckOutcome.REJECTED, []);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
index 7a58ed1cc..27d384570 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
@@ -23,6 +23,7 @@
- Added organization-wide management for the notice on AI-generated content. IT departments can switch it on for everybody, either fixed or as a default which users may change.
- Added a choice of how AI Studio encrypts API keys, tokens, and passwords when you export providers, data sources, or tool settings for your organization. The new protected format needs AI Studio v26.10.1 or newer on every computer that reads it, and your encryption secret stays the same. If you used AI Studio before, your exports keep the previous format until you switch, so your organization can update all computers first. You find the choice in the app settings, under Enterprise Administration.
- Added organization-wide management for the format in which exports encrypt API keys, tokens, and passwords. IT departments can set it for all their administrators, either fixed or as a default which users may change.
+- Added a way for IT departments to ask you for your personal API key on the start page. When the configuration of your organization brings providers that need your own key, the start page explains how to get it, checks the key with the provider, and stores it for all of these providers at once. Thanks, Dominic Neuburg (`donework`), for this contribution.
- Improved the Read Web Page tool. When you have not allowed the AI to choose web addresses freely, the tool now refuses every address that does not appear in your chat or in what the tools returned, instead of only asking the AI not to read it.
- Improved the installation on macOS: AI Studio is now signed and checked by Apple, so it opens like any other app. You no longer need the Terminal.
- Improved the installation on Windows: AI Studio is now signed, so Windows shows who published it. For a while, Windows may still ask you to confirm the installation until it knows the app better.
@@ -92,6 +93,7 @@
- Fixed AI Studio overlooking suspicious instructions (prompt injection) in files such as text or Markdown files when the instructions continued on the next line.
- Fixed AI Studio removing harmless lines from files such as text or Markdown files, mistaking them for suspicious instructions (prompt injection).
- Fixed AI Studio not checking the transcripts of audio and video files for suspicious instructions (prompt injection) in the Batch Processing assistant and when you load such a file into a field of an assistant. AI Studio now checks these transcripts like all other documents before the AI sees them, even very long ones.
+- Fixed AI Studio deleting the API keys you entered yourself for the providers of your organization when your organization changed the secret that protects its configuration. These keys now stay, so you no longer need to enter them again.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.0
diff --git a/app/Tests/Settings/UserProvidedAPIKeyOptionsTests.cs b/app/Tests/Settings/UserProvidedAPIKeyOptionsTests.cs
new file mode 100644
index 000000000..759bc104b
--- /dev/null
+++ b/app/Tests/Settings/UserProvidedAPIKeyOptionsTests.cs
@@ -0,0 +1,103 @@
+using AIStudio.Provider;
+using AIStudio.Settings;
+using AIStudio.Tools.PluginSystem;
+
+using Lua;
+using Lua.Standard;
+
+namespace AIStudio.Tests.Settings;
+
+///
+/// Checks how the providers of a configuration plugin read the options for the API key of the user.
+///
+///
+/// All three kinds of providers read both options through UserProvidedAPIKeyOptions. The tests read each
+/// kind the way a configuration plugin states it, so a kind which drops one of the options fails here.
+///
+[TestFixture]
+public sealed class UserProvidedAPIKeyOptionsTests
+{
+ private const string PROVIDER_ID = "8b2f4c6d-1e3a-4f5b-9c7d-2a4e6f8b0c1d";
+
+ [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
+ public async Task WithoutAnOptionTheOrganizationManagesTheAPIKey(PluginConfigurationObjectType type)
+ {
+ Assert.That(await ReadOptionsAsync(type), Is.EqualTo(new UserProvidedAPIKeyOptions(AllowUserProvidedAPIKey: false, PromptForUserAPIKey: false)));
+ }
+
+ [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
+ public async Task AllowingTheAPIKeyOfTheUserDoesNotAskForIt(PluginConfigurationObjectType type)
+ {
+ Assert.That(await ReadOptionsAsync(type, """["AllowUserProvidedAPIKey"] = true"""), Is.EqualTo(new UserProvidedAPIKeyOptions(AllowUserProvidedAPIKey: true, PromptForUserAPIKey: false)));
+ }
+
+ [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
+ public async Task AskingForTheAPIKeyOfTheUserAlsoAllowsIt(PluginConfigurationObjectType type)
+ {
+ Assert.That(await ReadOptionsAsync(type, """["PromptForUserAPIKey"] = true"""), Is.EqualTo(new UserProvidedAPIKeyOptions(AllowUserProvidedAPIKey: true, PromptForUserAPIKey: true)));
+ }
+
+ [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
+ public async Task AskingForTheAPIKeyOfTheUserOverridesAllowingNone(PluginConfigurationObjectType type)
+ {
+ Assert.That(await ReadOptionsAsync(type, """["AllowUserProvidedAPIKey"] = false""", """["PromptForUserAPIKey"] = true"""), Is.EqualTo(new UserProvidedAPIKeyOptions(AllowUserProvidedAPIKey: true, PromptForUserAPIKey: true)));
+ }
+
+ [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
+ public async Task AnOptionWhichIsNoBooleanCountsAsNotSet(PluginConfigurationObjectType type)
+ {
+ Assert.That(await ReadOptionsAsync(type, """["PromptForUserAPIKey"] = "yes" """), Is.EqualTo(new UserProvidedAPIKeyOptions(AllowUserProvidedAPIKey: false, PromptForUserAPIKey: false)));
+ }
+
+ ///
+ /// Exports a provider, adds the given options to it, and reads it back the way a configuration plugin does.
+ ///
+ /// The kind of provider.
+ /// The options as Lua table fields, e.g., ["PromptForUserAPIKey"] = true.
+ /// The options of the provider which was read back.
+ private static async Task ReadOptionsAsync(PluginConfigurationObjectType type, params string[] options)
+ {
+ var luaCode = type switch
+ {
+ PluginConfigurationObjectType.LLM_PROVIDER => new AIStudio.Settings.Provider(1, PROVIDER_ID, "Chat provider", LLMProviders.OPEN_AI, new Model("gpt-6", "GPT-6")).ExportAsConfigurationSection(),
+ PluginConfigurationObjectType.EMBEDDING_PROVIDER => new EmbeddingProvider(1, PROVIDER_ID, "Embedding provider", LLMProviders.OPEN_AI, new Model("text-embedding-3-small", "text-embedding-3-small")).ExportAsConfigurationSection(),
+ PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => new TranscriptionProvider(1, PROVIDER_ID, "Transcription provider", LLMProviders.OPEN_AI, new Model("whisper-1", "Whisper")).ExportAsConfigurationSection(),
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, "This type is no provider.")
+ };
+
+ var descriptor = PluginConfigurationObjectDescriptors.Get(type);
+ var section = descriptor.LuaTableName;
+ var optionLines = string.Join(Environment.NewLine, options.Select(option => $"CONFIG[\"{section}\"][1]{option}"));
+
+ var state = LuaState.Create();
+ state.OpenBasicLibrary();
+ state.OpenTableLibrary();
+
+ await state.DoStringAsync($$"""
+ CONFIG = {}
+ CONFIG["{{section}}"] = {}
+ {{luaCode}}
+ {{optionLines}}
+ """);
+
+ if (!state.Environment["CONFIG"].TryRead(out var configTable) ||
+ !configTable[section].TryRead(out var providersTable) ||
+ !providersTable[1].TryRead(out var providerTable))
+ throw new InvalidOperationException("The code of this test did not produce a provider table.");
+
+ if (!descriptor.TryParseSnippet(providerTable, out var provider) || provider is not IUserProvidedAPIKey apiKeyOptions)
+ throw new InvalidOperationException("The provider of this test could not be read.");
+
+ return new UserProvidedAPIKeyOptions(apiKeyOptions.AllowUserProvidedAPIKey, apiKeyOptions.PromptForUserAPIKey);
+ }
+}
\ No newline at end of file
diff --git a/app/Tests/Tools/EnterpriseManagedSecretsTests.cs b/app/Tests/Tools/EnterpriseManagedSecretsTests.cs
new file mode 100644
index 000000000..533a2342c
--- /dev/null
+++ b/app/Tests/Tools/EnterpriseManagedSecretsTests.cs
@@ -0,0 +1,44 @@
+using AIStudio.Provider;
+using AIStudio.Tools.Services;
+
+namespace AIStudio.Tests.Tools;
+
+///
+/// Checks which secrets AI Studio removes from the OS keyring when an organization changes its encryption secret.
+///
+///
+/// Only what the configuration of the organization brought along goes, since the next start of the configuration
+/// stores it again with the new secret. A key the user entered for a provider which allows it has no such way
+/// back: removing it made every user enter the key once more, and the home page asked them all again.
+///
+[TestFixture]
+public sealed class EnterpriseManagedSecretsTests
+{
+ private static readonly Guid PLUGIN_ID = new("44444444-4444-4444-4444-444444444444");
+
+ [Test]
+ public void TheKeyOfTheOrganizationGoes()
+ {
+ Assert.That(EnterpriseEnvironmentService.HoldsSecretOfTheOrganization(ProviderOfTheOrganization()), Is.True);
+ }
+
+ [Test]
+ public void TheKeyTheUserEnteredStays()
+ {
+ Assert.That(EnterpriseEnvironmentService.HoldsSecretOfTheOrganization(ProviderOfTheOrganization() with { AllowUserProvidedAPIKey = true }), Is.False);
+ }
+
+ [Test]
+ public void TheKeyTheHomePageAskedForStays()
+ {
+ Assert.That(EnterpriseEnvironmentService.HoldsSecretOfTheOrganization(ProviderOfTheOrganization() with { AllowUserProvidedAPIKey = true, PromptForUserAPIKey = true }), Is.False);
+ }
+
+ [Test]
+ public void AProviderOfTheUsersOwnIsNoConcernOfTheOrganization()
+ {
+ Assert.That(EnterpriseEnvironmentService.HoldsSecretOfTheOrganization(new AIStudio.Settings.Provider(1, Guid.NewGuid().ToString(), "My own provider", LLMProviders.OPEN_AI, new Model("gpt-6", "GPT-6"))), Is.False);
+ }
+
+ private static AIStudio.Settings.Provider ProviderOfTheOrganization() => new(1, Guid.NewGuid().ToString(), "Provider of the organization", LLMProviders.OPEN_AI, new Model("gpt-6", "GPT-6"), IsEnterpriseConfiguration: true, EnterpriseConfigurationPluginId: PLUGIN_ID);
+}
\ No newline at end of file
diff --git a/app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs b/app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs
index cd017d92f..7cb9a80e8 100644
--- a/app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs
+++ b/app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs
@@ -206,6 +206,30 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
[TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
public void AnUnencryptedAPIKeyIsLeftOut(PluginConfigurationObjectType type) => AssertAPIKeyLeftOut(Import(ExportProvider(type, API_KEY).Snippet, type));
+ [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
+ [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
+ public void AnImportedProviderNeverAsksForTheAPIKeyOfTheUser(PluginConfigurationObjectType type)
+ {
+ //
+ // An export never writes the option, but a snippet may come from a configuration plugin of an
+ // organization. The option implies the user-provided key, so both have to go:
+ //
+ var snippet = ExportProvider(type, null).Snippet;
+ var tableStart = snippet.IndexOf("= {", StringComparison.Ordinal) + "= {".Length;
+ var snippetWithPrompt = snippet.Insert(tableStart, """
+
+ ["PromptForUserAPIKey"] = true,
+ """);
+
+ var imported = (IUserProvidedAPIKey)Import(snippetWithPrompt, type).ConfigurationObject;
+ Assert.Multiple(() =>
+ {
+ Assert.That(imported.PromptForUserAPIKey, Is.False);
+ Assert.That(imported.AllowUserProvidedAPIKey, Is.False);
+ });
+ }
+
[Test]
public void TheTokenOfADataSourceIsDecrypted() => Assert.That(Import(ExportDataSource(AuthMethod.TOKEN).Snippet, PluginConfigurationObjectType.DATA_SOURCE).Secret, Is.EqualTo(TOKEN));
diff --git a/app/Tests/Tools/UserAPIKeys/UserAPIKeyCheckTests.cs b/app/Tests/Tools/UserAPIKeys/UserAPIKeyCheckTests.cs
new file mode 100644
index 000000000..606aef60e
--- /dev/null
+++ b/app/Tests/Tools/UserAPIKeys/UserAPIKeyCheckTests.cs
@@ -0,0 +1,45 @@
+using AIStudio.Provider;
+using AIStudio.Tools.UserAPIKeys;
+
+namespace AIStudio.Tests.Tools.UserAPIKeys;
+
+///
+/// Checks what AI Studio reads from the models a provider loads with the API key the user entered.
+///
+///
+/// Only a refusal keeps the key out of the OS keyring, and only a list with models counts as accepted. Many
+/// providers succeed with an empty list whatever the key, for example for a kind of model they do not offer;
+/// telling the user that such a key was accepted would be a promise nobody checked.
+///
+[TestFixture]
+public sealed class UserAPIKeyCheckTests
+{
+ [TestCase(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY)]
+ [TestCase(ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR)]
+ public void ARefusedSignInRejectsTheKey(ModelLoadFailureReason failureReason)
+ {
+ Assert.That(PendingUserAPIKeys.ClassifyCheck(ModelLoadResult.Failure(failureReason)), Is.EqualTo(UserAPIKeyCheckOutcome.REJECTED));
+ }
+
+ [Test]
+ public void ModelsLoadedWithTheKeyAcceptIt()
+ {
+ Assert.That(PendingUserAPIKeys.ClassifyCheck(ModelLoadResult.FromModels([new Model("gpt-6", "GPT-6")])), Is.EqualTo(UserAPIKeyCheckOutcome.ACCEPTED));
+ }
+
+ [Test]
+ public void AnEmptyListTellsNothingAboutTheKey()
+ {
+ Assert.That(PendingUserAPIKeys.ClassifyCheck(ModelLoadResult.FromModels([])), Is.EqualTo(UserAPIKeyCheckOutcome.NOT_CHECKED));
+ }
+
+ [TestCase(ModelLoadFailureReason.INSUFFICIENT_QUOTA)]
+ [TestCase(ModelLoadFailureReason.TOO_MANY_REQUESTS)]
+ [TestCase(ModelLoadFailureReason.PROVIDER_UNAVAILABLE)]
+ [TestCase(ModelLoadFailureReason.INVALID_RESPONSE)]
+ [TestCase(ModelLoadFailureReason.UNKNOWN)]
+ public void AnyOtherFailureTellsNothingAboutTheKey(ModelLoadFailureReason failureReason)
+ {
+ Assert.That(PendingUserAPIKeys.ClassifyCheck(ModelLoadResult.Failure(failureReason)), Is.EqualTo(UserAPIKeyCheckOutcome.NOT_CHECKED));
+ }
+}
\ No newline at end of file
diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md
index 28884bbaf..5e0c7bc00 100644
--- a/documentation/Enterprise IT.md
+++ b/documentation/Enterprise IT.md
@@ -963,6 +963,45 @@ document: if your configuration stops offering this provider, AI Studio removes
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).
+### Asking users for their API key
+
+With `AllowUserProvidedAPIKey` alone, users have to find the provider in the settings and enter
+their key there. Set `PromptForUserAPIKey` instead, and AI Studio asks for the key itself: as long
+as such a provider has no API key, the home page shows a panel with an input field for the key.
+The panel comes first and is open, even when your configuration hides the other panels of the
+home page. AI Studio also stays on the home page after starting, even when another start page is
+configured, until the key is there. `PromptForUserAPIKey` implies `AllowUserProvidedAPIKey`, and
+it works for LLM, embedding, and transcription providers alike. Other API keys, such as the one of
+a web search, are not covered: they belong to the tool settings.
+
+All providers of one configuration which set the option share one panel and one key: the user
+enters the key once, and AI Studio stores it for every one of them. Therefore, mark only providers
+which accept the same key, for example several models behind one Open WebUI server. Providers
+which need another key belong in a configuration of their own.
+
+Before storing the key, AI Studio loads the models of the first of these providers which can list
+them. A key the provider rejects is not stored, and the user is told so. When the
+provider cannot be reached, or none of the providers can list its models, the key is stored with
+a warning, so that an outage does not keep anyone from finishing the setup. A few providers, such
+as Perplexity, answer with a fixed list of models whatever the key; AI Studio cannot tell a wrong
+key there.
+
+Explain how users obtain their key with `CONFIG["USER_API_KEY_PROMPT"]`. Its Markdown is shown
+above the input field and is required; the title is optional:
+
+```lua
+CONFIG["USER_API_KEY_PROMPT"] = {
+ ["Title"] = "Please add your API key",
+ ["Markdown"] = [===[
+ To use the AI models of your organization, you need a personal API key:
+
+ 1. Open [Open WebUI](https://example.org) and sign in.
+ 2. Click your profile picture, then "Settings" and "Account".
+ 3. Create an API key, copy it, and paste it below.
+ ]===]
+}
+```
+
## Describing your own models
AI Studio knows what the models of the large vendors can do, and reads that knowledge from their