From 3e6e3bdcbdbe3c5619447d6614b9904127fd1d6c Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 25 May 2026 17:32:54 +0200 Subject: [PATCH] Fixed error messages for provider requests (#778) --- .../Assistants/AssistantBase.razor.cs | 44 +++- .../Assistants/I18N/allTexts.lua | 18 ++ app/MindWork AI Studio/Chat/ContentText.cs | 107 +++++---- .../Components/VoiceRecorder.razor.cs | 5 +- app/MindWork AI Studio/Pages/Writer.razor.cs | 33 ++- .../plugin.lua | 18 ++ .../plugin.lua | 18 ++ .../Provider/Anthropic/ProviderAnthropic.cs | 1 + .../Provider/BaseProvider.cs | 224 +++++++++++++++++- .../Provider/Google/ProviderGoogle.cs | 1 + .../Provider/ModelLoadFailureReason.cs | 2 + .../ModelLoadFailureReasonExtensions.cs | 2 + .../Provider/OpenAI/ProviderOpenAI.cs | 81 +++++++ .../Provider/ProviderRequestException.cs | 25 ++ .../Provider/ProviderRequestFailureReason.cs | 8 + .../Provider/SelfHosted/ProviderSelfHosted.cs | 6 +- .../Provider/TranscriptionResult.cs | 4 +- .../Tools/AIJobs/AIJobService.cs | 20 ++ .../wwwroot/changelog/v26.5.5.md | 1 + 19 files changed, 541 insertions(+), 77 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/ProviderRequestException.cs create mode 100644 app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index d9b553dd..d9cf2afe 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -328,22 +328,40 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.isProcessing = true; this.StateHasChanged(); - // Use the selected provider to get the AI response. - // By awaiting this line, we wait for the entire - // content to be streamed. - this.ChatThread = await aiText.CreateFromProviderAsync(this.ProviderSettings.CreateProvider(), this.ProviderSettings.Model, this.LastUserPrompt, this.ChatThread, this.CancellationTokenSource!.Token); - - this.isProcessing = false; - this.StateHasChanged(); - - if(manageCancellationLocally) + try { - this.CancellationTokenSource.Dispose(); - this.CancellationTokenSource = null; + // Use the selected provider to get the AI response. + // By awaiting this line, we wait for the entire + // content to be streamed. + this.ChatThread = await aiText.CreateFromProviderAsync(this.ProviderSettings.CreateProvider(), this.ProviderSettings.Model, this.LastUserPrompt, this.ChatThread, this.CancellationTokenSource!.Token); + + // Return the AI response: + return aiText.Text; } + catch (ProviderRequestException e) + { + this.Logger.LogError(e, "The provider request failed for assistant '{AssistantTitle}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", this.Title, e.StatusCode, e.ReasonPhrase, e.ResponseBody); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); + + if (this.resultingContentBlock is not null && string.IsNullOrWhiteSpace(aiText.Text)) + { + this.ChatThread?.Blocks.Remove(this.resultingContentBlock); + this.resultingContentBlock = null; + } + + return string.Empty; + } + finally + { + this.isProcessing = false; + this.StateHasChanged(); - // Return the AI response: - return aiText.Text; + if(manageCancellationLocally) + { + this.CancellationTokenSource?.Dispose(); + this.CancellationTokenSource = null; + } + } } private async Task CancelStreaming() diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 44ac8adc..7020549e 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -6469,6 +6469,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Your stage directions" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}'" +-- The provider '{0}' reported an error while streaming the response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1008706234"] = "The provider '{0}' reported an error while streaming the response." + +-- The provider rejected the request because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1028424693"] = "The provider rejected the request because too many requests were sent. Please wait a moment and try again." + -- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding." @@ -6502,6 +6508,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T3759732886"] = "We tried to -- We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}'" +-- The provider '{0}' reported an error: {1} +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "The provider '{0}' reported an error: {1}" + -- The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T1014558951"] = "The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe." @@ -6562,6 +6571,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "no model selected" -- We could not load models from '{0}'. The account or API key does not have the required permissions. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "We could not load models from '{0}'. The account or API key does not have the required permissions." +-- We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T155481725"] = "We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again." + -- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "We could not load models from '{0}'. The API key is probably missing, invalid, or expired." @@ -6571,9 +6583,15 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T21156887 -- We could not load models from '{0}' because the provider returned an unexpected response. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "We could not load models from '{0}' because the provider returned an unexpected response." +-- We could not load models from '{0}' because the account appears to have no API credits left. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T373339048"] = "We could not load models from '{0}' because the account appears to have no API credits left." + -- We could not load models from '{0}' due to an unknown error. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "We could not load models from '{0}' due to an unknown error." +-- It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::OPENAI::PROVIDEROPENAI::T757371511"] = "It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Model as configured by whisper.cpp" diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 6a116278..4c8be646 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -93,59 +93,70 @@ public sealed class ContentText : IContent // Start another thread by using a task to uncouple // the UI thread from the AI processing: - await Task.Run(async () => + try { - // We show the waiting animation until we get the first response: - this.InitialRemoteWait = true; - - // Iterate over the responses from the AI: - await foreach (var contentStreamChunk in provider.StreamChatCompletion(chatModel, chatThread, settings, token)) + await Task.Run(async () => { - // When the user cancels the request, we stop the loop: - if (token.IsCancellationRequested) - break; - - // Stop the waiting animation: - this.InitialRemoteWait = false; - this.IsStreaming = true; - - // Add the response to the text: - this.Text += contentStreamChunk; - - // Merge the sources: - this.Sources.MergeSources(contentStreamChunk.Sources); - - // Notify the UI that the content has changed, - // depending on the energy saving mode: - var now = DateTimeOffset.Now; - switch (settings.ConfigurationData.App.IsSavingEnergy) + try { - // Energy saving mode is off. We notify the UI - // as fast as possible -- no matter the odds: - case false: - await this.StreamingEvent(); - break; - - // Energy saving mode is on. We notify the UI - // only when the time between two events is - // greater than the minimum time: - case true when now - last > MIN_TIME: - last = now; - await this.StreamingEvent(); - break; + // We show the waiting animation until we get the first response: + this.InitialRemoteWait = true; + + // Iterate over the responses from the AI: + await foreach (var contentStreamChunk in provider.StreamChatCompletion(chatModel, chatThread, settings, token)) + { + // When the user cancels the request, we stop the loop: + if (token.IsCancellationRequested) + break; + + // Stop the waiting animation: + this.InitialRemoteWait = false; + this.IsStreaming = true; + + // Add the response to the text: + this.Text += contentStreamChunk; + + // Merge the sources: + this.Sources.MergeSources(contentStreamChunk.Sources); + + // Notify the UI that the content has changed, + // depending on the energy saving mode: + var now = DateTimeOffset.Now; + switch (settings.ConfigurationData.App.IsSavingEnergy) + { + // Energy saving mode is off. We notify the UI + // as fast as possible -- no matter the odds: + case false: + await this.StreamingEvent(); + break; + + // Energy saving mode is on. We notify the UI + // only when the time between two events is + // greater than the minimum time: + case true when now - last > MIN_TIME: + last = now; + await this.StreamingEvent(); + break; + } + } } - } - - // Stop the waiting animation (in case the loop - // was stopped, or no content was received): - this.InitialRemoteWait = false; - this.IsStreaming = false; - }, token); - - this.Text = this.Text.RemoveThinkTags().Trim(); + finally + { + // Stop the waiting animation (in case the loop + // was stopped, or no content was received): + this.InitialRemoteWait = false; + this.IsStreaming = false; + } + }, token); + } + finally + { + this.Text = this.Text.RemoveThinkTags().Trim(); - // Inform the UI that the streaming is done: - await this.StreamingDone(); + // Inform the UI that the streaming is done: + await this.StreamingDone(); + } + return chatThread; } diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index fd756af7..669932f6 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -366,7 +366,10 @@ public partial class VoiceRecorder : MSGComponentBase if (!transcriptionResult.Success) { this.Logger.LogWarning("The transcription request failed."); - await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("Unfortunately, there was an error communicating with the AI system."))); + var userMessage = string.IsNullOrWhiteSpace(transcriptionResult.ErrorMessage) + ? this.T("Unfortunately, there was an error communicating with the AI system.") + : transcriptionResult.ErrorMessage; + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, userMessage)); return; } diff --git a/app/MindWork AI Studio/Pages/Writer.razor.cs b/app/MindWork AI Studio/Pages/Writer.razor.cs index 9f1dcd26..a2a70ea3 100644 --- a/app/MindWork AI Studio/Pages/Writer.razor.cs +++ b/app/MindWork AI Studio/Pages/Writer.razor.cs @@ -10,6 +10,7 @@ namespace AIStudio.Pages; public partial class Writer : MSGComponentBase { + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); private readonly Timer typeTimer = new(TimeSpan.FromMilliseconds(1_500)); @@ -106,22 +107,38 @@ public partial class Writer : MSGComponentBase InitialRemoteWait = true, }; - this.chatThread?.Blocks.Add(new ContentBlock + var aiBlock = new ContentBlock { Time = time, ContentType = ContentType.TEXT, Role = ChatRole.AI, Content = aiText, - }); + }; + + this.chatThread?.Blocks.Add(aiBlock); this.isStreaming = true; this.StateHasChanged(); - - this.chatThread = await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(), this.providerSettings.Model, lastUserPrompt, this.chatThread); - this.suggestion = aiText.Text; - - this.isStreaming = false; - this.StateHasChanged(); + + try + { + this.chatThread = await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(), this.providerSettings.Model, lastUserPrompt, this.chatThread); + this.suggestion = aiText.Text; + } + catch (ProviderRequestException e) + { + LOGGER.LogError(e, "The provider request failed for writer suggestions. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", e.StatusCode, e.ReasonPhrase, e.ResponseBody); + await this.MessageBus.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); + this.suggestion = string.Empty; + + if (string.IsNullOrWhiteSpace(aiText.Text)) + this.chatThread?.Blocks.Remove(aiBlock); + } + finally + { + this.isStreaming = false; + this.StateHasChanged(); + } } private void AcceptEntireSuggestion() 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 77b78965..eba11f38 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 @@ -6471,6 +6471,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Ihre Regieanweisungen" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "Wir haben versucht, mit dem LLM-Anbieter „{0}“ (Typ={1}) zu kommunizieren. Der Server ist möglicherweise nicht erreichbar oder hat Probleme. Die Nachricht des Anbieters lautet: „{2}“" +-- The provider '{0}' reported an error while streaming the response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1008706234"] = "Der Anbieter „{0}“ hat einen Fehler beim Streamen der Antwort gemeldet." + +-- The provider rejected the request because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1028424693"] = "Der Anbieter hat die Anfrage abgelehnt, weil zu viele Anfragen gesendet wurden. Bitte warten Sie einen Moment und versuchen Sie es erneut." + -- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "Die Anfrage an den LLM-Anbieter „{0}“ (Typ={1}) hat nach {2} während „{3}“ das Zeitlimit überschritten. Bitte versuchen Sie es erneut oder prüfen Sie, ob der Anbieter noch antwortet." @@ -6504,6 +6510,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T3759732886"] = "Wir haben ve -- We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "Wir haben versucht, mit dem LLM-Anbieter „{0}“ (Typ={1}) zu kommunizieren. Die Daten des Chats, einschließlich aller Dateianhänge, sind vermutlich zu groß für das ausgewählte Modell und den Anbieter. Die Nachricht des Anbieters lautet: „{2}“" +-- The provider '{0}' reported an error: {1} +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "Der Anbieter „{0}“ hat einen Fehler gemeldet: {1}" + -- The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T1014558951"] = "Das Vertrauensniveau dieses Anbieters wurde **noch nicht** gründlich **untersucht und bewertet**. Wir wissen nicht, ob ihre Daten sicher sind." @@ -6564,6 +6573,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "Kein Modell ausgew -- We could not load models from '{0}'. The account or API key does not have the required permissions. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "Wir konnten keine Modelle von '{0}' laden. Das Konto oder der API-Schlüssel verfügt nicht über die erforderlichen Berechtigungen." +-- We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T155481725"] = "Wir konnten keine Modelle von „{0}“ laden, da zu viele Anfragen gesendet wurden. Bitte warten Sie einen Moment und versuchen Sie es erneut." + -- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "Modelle aus '{0}' konnten nicht geladen werden. Wahrscheinlich fehlt der API-Schlüssel, ist ungültig oder abgelaufen." @@ -6573,9 +6585,15 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T21156887 -- We could not load models from '{0}' because the provider returned an unexpected response. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "Wir konnten keine Modelle von '{0}' laden, da der Anbieter eine unerwartete Antwort zurückgegeben hat." +-- We could not load models from '{0}' because the account appears to have no API credits left. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T373339048"] = "Modelle konnten nicht von „{0}“ geladen werden, da das Konto offenbar keine API-Guthaben mehr hat." + -- We could not load models from '{0}' due to an unknown error. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "Wir konnten die Modelle aus '{0}' aufgrund eines unbekannten Fehlers nicht laden." +-- It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::OPENAI::PROVIDEROPENAI::T757371511"] = "Anscheinend haben Sie bei OpenAI kein API-Guthaben mehr. Bitte fügen Sie Ihrem Konto Guthaben hinzu und versuchen Sie es erneut." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Modell wie in whisper.cpp konfiguriert" 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 99fa0cd7..01e80406 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 @@ -6471,6 +6471,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Your stage directions" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}'" +-- The provider '{0}' reported an error while streaming the response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1008706234"] = "The provider '{0}' reported an error while streaming the response." + +-- The provider rejected the request because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1028424693"] = "The provider rejected the request because too many requests were sent. Please wait a moment and try again." + -- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding." @@ -6504,6 +6510,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T3759732886"] = "We tried to -- We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}'" +-- The provider '{0}' reported an error: {1} +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "The provider '{0}' reported an error: {1}" + -- The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T1014558951"] = "The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe." @@ -6564,6 +6573,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "no model selected" -- We could not load models from '{0}'. The account or API key does not have the required permissions. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "We could not load models from '{0}'. The account or API key does not have the required permissions." +-- We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T155481725"] = "We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again." + -- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "We could not load models from '{0}'. The API key is probably missing, invalid, or expired." @@ -6573,9 +6585,15 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T21156887 -- We could not load models from '{0}' because the provider returned an unexpected response. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "We could not load models from '{0}' because the provider returned an unexpected response." +-- We could not load models from '{0}' because the account appears to have no API credits left. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T373339048"] = "We could not load models from '{0}' because the account appears to have no API credits left." + -- We could not load models from '{0}' due to an unknown error. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "We could not load models from '{0}' due to an unknown error." +-- It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::OPENAI::PROVIDEROPENAI::T757371511"] = "It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Model as configured by whisper.cpp" diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index c881eb13..5274358a 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -179,6 +179,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " { System.Net.HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, System.Net.HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + System.Net.HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, }, requestConfigurator: (request, secretKey) => diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 81d92c3e..86753d7a 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -167,10 +167,18 @@ public abstract class BaseProvider : IProvider, ISecretId { HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, }; + protected ModelLoadFailureReason GetModelLoadFailureReason(HttpResponseMessage response, string responseBody) => this.ClassifyProviderRequestFailure(response.StatusCode, responseBody) switch + { + ProviderRequestFailureReason.INSUFFICIENT_QUOTA => ModelLoadFailureReason.INSUFFICIENT_QUOTA, + ProviderRequestFailureReason.TOO_MANY_REQUESTS => ModelLoadFailureReason.TOO_MANY_REQUESTS, + _ => GetDefaultModelLoadFailureReason(response), + }; + protected async Task LoadModelsResponse( SecretStoreType storeType, string requestPath, @@ -198,7 +206,8 @@ public abstract class BaseProvider : IProvider, ISecretId var responseBody = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { - var failureReason = failureReasonSelector?.Invoke(response, responseBody) ?? GetDefaultModelLoadFailureReason(response); + var failureReason = failureReasonSelector?.Invoke(response, responseBody) ?? this.GetModelLoadFailureReason(response, responseBody); + this.logger.LogError("Model loading request failed with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", response.StatusCode, response.ReasonPhrase, responseBody); return FailedModelLoadResult(failureReason, $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{responseBody}'"); } @@ -222,6 +231,168 @@ public abstract class BaseProvider : IProvider, ISecretId return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); } } + + protected virtual string GetProviderRequestFailureUserMessage(ProviderRequestFailureReason failureReason) => failureReason switch + { + ProviderRequestFailureReason.TOO_MANY_REQUESTS => TB("The provider rejected the request because too many requests were sent. Please wait a moment and try again."), + _ => string.Empty, + }; + + protected virtual ProviderRequestFailureReason ClassifyProviderRequestFailure(HttpStatusCode statusCode, string responseBody) + { + if (statusCode is not HttpStatusCode.TooManyRequests) + return ProviderRequestFailureReason.NONE; + + return ProviderRequestFailureReason.TOO_MANY_REQUESTS; + } + + protected virtual ProviderRequestFailureReason ClassifyProviderRequestFailure(string? errorCode, string? errorType, string? errorMessage, string responseBody) + { + if (IsTooManyRequestsError(errorCode) || IsTooManyRequestsError(errorType) || IsTooManyRequestsError(errorMessage)) + return ProviderRequestFailureReason.TOO_MANY_REQUESTS; + + return ProviderRequestFailureReason.NONE; + } + + private static bool IsTooManyRequestsError(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return value.Equals("rate_limit_exceeded", StringComparison.OrdinalIgnoreCase) || + value.Equals("too_many_requests", StringComparison.OrdinalIgnoreCase) || + value.Equals("too_many_request", StringComparison.OrdinalIgnoreCase) || + value.Contains("too many requests", StringComparison.OrdinalIgnoreCase) || + value.Contains("rate limit", StringComparison.OrdinalIgnoreCase) || + value.Contains("rate_limit", StringComparison.OrdinalIgnoreCase) || + value.Contains("throttl", StringComparison.OrdinalIgnoreCase); + } + + private bool TryCreateProviderRequestExceptionFromStreamLine(string providerName, string line, out ProviderRequestException exception) + { + exception = new(); + + if (!line.StartsWith("data: ", StringComparison.InvariantCulture)) + return false; + + var jsonData = line[6..].Trim(); + if (string.IsNullOrWhiteSpace(jsonData) || jsonData is "[DONE]") + return false; + + try + { + using var document = JsonDocument.Parse(jsonData); + var root = document.RootElement; + if (!IsProviderStreamFailure(root)) + return false; + + var eventType = TryGetString(root, "type"); + TryGetProviderStreamError(root, out var errorCode, out var errorType, out var errorMessage); + var failureReason = this.ClassifyProviderRequestFailure(errorCode, errorType, errorMessage, jsonData); + var userMessage = this.GetProviderRequestFailureUserMessage(failureReason); + if (string.IsNullOrWhiteSpace(userMessage)) + { + userMessage = string.IsNullOrWhiteSpace(errorMessage) + ? string.Format(TB("The provider '{0}' reported an error while streaming the response."), this.InstanceName) + : string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, errorMessage); + } + + this.logger.LogError("The {ProviderName} stream returned an error for provider '{ProviderInstanceName}' (provider={ProviderType}). EventType={StreamEventType}, ErrorCode={ErrorCode}, ErrorType={ErrorType}, ErrorMessage='{ErrorMessage}', Body='{ErrorBody}'", providerName, this.InstanceName, this.Provider, eventType, errorCode, errorType, errorMessage, jsonData); + exception = new ProviderRequestException(failureReason, userMessage, responseBody: jsonData); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool IsProviderStreamFailure(JsonElement root) + { + var eventType = TryGetString(root, "type"); + if (eventType is not null && ( + eventType.Equals("error", StringComparison.OrdinalIgnoreCase) || + eventType.Equals("response.error", StringComparison.OrdinalIgnoreCase) || + eventType.Equals("response.failed", StringComparison.OrdinalIgnoreCase))) + return true; + + if (HasObjectProperty(root, "error")) + return true; + + if (IsTooManyRequestsError(TryGetString(root, "code")) || + IsTooManyRequestsError(TryGetString(root, "type")) || + IsTooManyRequestsError(TryGetString(root, "message"))) + return true; + + if (TryGetString(root, "message") is not null && + (TryGetString(root, "code") is not null || TryGetString(root, "type") is not null) && + !root.TryGetProperty("choices", out _) && + !root.TryGetProperty("delta", out _)) + return true; + + if (!root.TryGetProperty("response", out var responseElement) || responseElement.ValueKind is not JsonValueKind.Object) + return false; + + if (HasObjectProperty(responseElement, "error")) + return true; + + var responseStatus = TryGetString(responseElement, "status"); + return responseStatus is not null && responseStatus.Equals("failed", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasObjectProperty(JsonElement element, string propertyName) + { + return element.ValueKind is JsonValueKind.Object && + element.TryGetProperty(propertyName, out var propertyElement) && + propertyElement.ValueKind is JsonValueKind.Object; + } + + private static void TryGetProviderStreamError(JsonElement root, out string? errorCode, out string? errorType, out string? errorMessage) + { + errorCode = null; + errorType = null; + errorMessage = null; + + if (TryGetErrorElement(root, out var errorElement)) + { + errorCode = TryGetString(errorElement, "code"); + errorType = TryGetString(errorElement, "type"); + errorMessage = TryGetString(errorElement, "message"); + return; + } + + errorCode = TryGetString(root, "code"); + errorType = TryGetString(root, "type"); + errorMessage = TryGetString(root, "message"); + } + + private static bool TryGetErrorElement(JsonElement root, out JsonElement errorElement) + { + if (root.ValueKind is JsonValueKind.Object && + root.TryGetProperty("error", out errorElement) && + errorElement.ValueKind is JsonValueKind.Object) + return true; + + if (root.ValueKind is JsonValueKind.Object && + root.TryGetProperty("response", out var responseElement) && + responseElement.ValueKind is JsonValueKind.Object && + responseElement.TryGetProperty("error", out errorElement) && + errorElement.ValueKind is JsonValueKind.Object) + return true; + + errorElement = default; + return false; + } + + private static string? TryGetString(JsonElement element, string propertyName) + { + if (element.ValueKind is not JsonValueKind.Object || + !element.TryGetProperty(propertyName, out var propertyElement) || + propertyElement.ValueKind is not JsonValueKind.String) + return null; + + return propertyElement.GetString(); + } /// /// Sends a request and handles rate limiting by exponential backoff. @@ -239,6 +410,10 @@ public abstract class BaseProvider : IProvider, ISecretId var retry = 0; var response = default(HttpResponseMessage); var errorMessage = string.Empty; + var lastProviderRequestFailure = ProviderRequestFailureReason.NONE; + HttpStatusCode? lastResponseStatusCode = null; + var lastResponseReasonPhrase = string.Empty; + var lastErrorBody = string.Empty; while (retry++ < MAX_RETRIES) { using var request = await requestBuilder(); @@ -266,10 +441,24 @@ public abstract class BaseProvider : IProvider, ISecretId if (nextResponse.IsSuccessStatusCode) { response = nextResponse; + errorMessage = string.Empty; + lastProviderRequestFailure = ProviderRequestFailureReason.NONE; break; } var errorBody = await nextResponse.Content.ReadAsStringAsync(effectiveCancellationToken); + lastResponseStatusCode = nextResponse.StatusCode; + lastResponseReasonPhrase = nextResponse.ReasonPhrase ?? string.Empty; + lastErrorBody = errorBody; + var providerRequestFailure = this.ClassifyProviderRequestFailure(nextResponse.StatusCode, errorBody); + lastProviderRequestFailure = providerRequestFailure; + if (providerRequestFailure is ProviderRequestFailureReason.INSUFFICIENT_QUOTA) + { + var userMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure); + this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); + throw new ProviderRequestException(providerRequestFailure, userMessage, nextResponse.StatusCode, nextResponse.ReasonPhrase ?? string.Empty, errorBody); + } + if (nextResponse.StatusCode is HttpStatusCode.Forbidden) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Block, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). You might not be able to use this provider from your location. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); @@ -340,6 +529,13 @@ public abstract class BaseProvider : IProvider, ISecretId if(retry >= MAX_RETRIES || !string.IsNullOrWhiteSpace(errorMessage)) { + if (lastProviderRequestFailure is not ProviderRequestFailureReason.NONE) + { + var userMessage = this.GetProviderRequestFailureUserMessage(lastProviderRequestFailure); + this.logger.LogError("The request to provider '{ProviderInstanceName}' (provider={ProviderType}) failed after {MaxRetries} retries with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}'): {ErrorMessage}", this.InstanceName, this.Provider, MAX_RETRIES, lastResponseStatusCode, lastResponseReasonPhrase, lastErrorBody, userMessage); + throw new ProviderRequestException(lastProviderRequestFailure, userMessage, lastResponseStatusCode, lastResponseReasonPhrase, lastErrorBody); + } + await MessageBus.INSTANCE.SendError(new DataErrorMessage(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). Even after {2} retries, there were some problems with the request. The provider message is: '{3}'."), this.InstanceName, this.Provider, MAX_RETRIES, errorMessage))); return new HttpRateLimitedStreamResult(false, true, errorMessage ?? $"Failed after {MAX_RETRIES} retries; no provider message available", response); } @@ -380,6 +576,10 @@ public abstract class BaseProvider : IProvider, ISecretId // Add a stream reader to read the stream, line by line: streamReader = new StreamReader(providerStream); } + catch(ProviderRequestException) + { + throw; + } catch(Exception e) { if (token.IsCancellationRequested) @@ -461,6 +661,9 @@ public abstract class BaseProvider : IProvider, ISecretId if (string.IsNullOrWhiteSpace(line)) continue; + if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) + throw providerRequestException; + // Skip lines that do not start with "data: ". Regard // to the specification, we only want to read the data lines: if (!line.StartsWith("data: ", StringComparison.InvariantCulture)) @@ -574,6 +777,10 @@ public abstract class BaseProvider : IProvider, ISecretId // Add a stream reader to read the stream, line by line: streamReader = new StreamReader(providerStream); } + catch(ProviderRequestException) + { + throw; + } catch(Exception e) { if (token.IsCancellationRequested) @@ -655,6 +862,9 @@ public abstract class BaseProvider : IProvider, ISecretId if (string.IsNullOrWhiteSpace(line)) continue; + if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) + throw providerRequestException; + // Check if the line is the end of the stream: if (line.StartsWith("event: response.completed", StringComparison.InvariantCulture)) yield break; @@ -869,7 +1079,8 @@ public abstract class BaseProvider : IProvider, ISecretId if (!response.IsSuccessStatusCode) { this.logger.LogError("Transcription request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); - return TranscriptionResult.Failure(); + var providerRequestFailure = this.ClassifyProviderRequestFailure(response.StatusCode, responseBody); + return TranscriptionResult.Failure(this.GetProviderRequestFailureUserMessage(providerRequestFailure)); } var transcriptionResponse = JsonSerializer.Deserialize(responseBody, JSON_SERIALIZER_OPTIONS); @@ -937,11 +1148,16 @@ public abstract class BaseProvider : IProvider, ISecretId // Set the content: request.Content = new StringContent(embeddingRequest, Encoding.UTF8, "application/json"); using var response = await this.HttpClient.SendAsync(request, token); - var responseBody = response.Content.ReadAsStringAsync(token).Result; + var responseBody = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { this.logger.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); + var providerRequestFailure = this.ClassifyProviderRequestFailure(response.StatusCode, responseBody); + var userMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure); + if (!string.IsNullOrWhiteSpace(userMessage)) + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage)); + return []; } @@ -1118,4 +1334,4 @@ public abstract class BaseProvider : IProvider, ISecretId _ => string.Empty, }; -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index 97ef11d5..d83d21b7 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -200,6 +200,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener { System.Net.HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, System.Net.HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, + System.Net.HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, }); } diff --git a/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs b/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs index b24ce1d4..786bfedd 100644 --- a/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs +++ b/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs @@ -5,6 +5,8 @@ public enum ModelLoadFailureReason NONE, INVALID_OR_MISSING_API_KEY, AUTHENTICATION_OR_PERMISSION_ERROR, + INSUFFICIENT_QUOTA, + TOO_MANY_REQUESTS, PROVIDER_UNAVAILABLE, INVALID_RESPONSE, UNKNOWN, diff --git a/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs b/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs index eaf7dcb7..542fbccb 100644 --- a/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs +++ b/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs @@ -10,6 +10,8 @@ public static class ModelLoadFailureReasonExtensions { ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY => string.Format(TB("We could not load models from '{0}'. The API key is probably missing, invalid, or expired."), providerName), ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR => string.Format(TB("We could not load models from '{0}'. The account or API key does not have the required permissions."), providerName), + ModelLoadFailureReason.INSUFFICIENT_QUOTA => string.Format(TB("We could not load models from '{0}' because the account appears to have no API credits left."), providerName), + ModelLoadFailureReason.TOO_MANY_REQUESTS => string.Format(TB("We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again."), providerName), ModelLoadFailureReason.PROVIDER_UNAVAILABLE => string.Format(TB("We could not load models from '{0}' because the provider is currently unavailable or could not be reached."), providerName), ModelLoadFailureReason.INVALID_RESPONSE => string.Format(TB("We could not load models from '{0}' because the provider returned an unexpected response."), providerName), ModelLoadFailureReason.UNKNOWN => string.Format(TB("We could not load models from '{0}' due to an unknown error."), providerName), diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index aa9fb49b..80161caf 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text; @@ -5,6 +6,7 @@ using System.Text.Json; using AIStudio.Chat; using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; namespace AIStudio.Provider.OpenAI; @@ -15,6 +17,8 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ProviderOpenAI).Namespace, nameof(ProviderOpenAI)); + #region Implementation of IProvider /// @@ -26,6 +30,28 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https /// public override bool HasModelLoadingCapability => true; + protected override ProviderRequestFailureReason ClassifyProviderRequestFailure(HttpStatusCode statusCode, string responseBody) + { + if (statusCode is HttpStatusCode.TooManyRequests && HasInsufficientQuotaError(responseBody)) + return ProviderRequestFailureReason.INSUFFICIENT_QUOTA; + + return base.ClassifyProviderRequestFailure(statusCode, responseBody); + } + + protected override ProviderRequestFailureReason ClassifyProviderRequestFailure(string? errorCode, string? errorType, string? errorMessage, string responseBody) + { + if (IsInsufficientQuota(errorCode) || IsInsufficientQuota(errorType) || HasInsufficientQuotaError(responseBody)) + return ProviderRequestFailureReason.INSUFFICIENT_QUOTA; + + return base.ClassifyProviderRequestFailure(errorCode, errorType, errorMessage, responseBody); + } + + protected override string GetProviderRequestFailureUserMessage(ProviderRequestFailureReason failureReason) => failureReason switch + { + ProviderRequestFailureReason.INSUFFICIENT_QUOTA => TB("It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again."), + _ => base.GetProviderRequestFailureUserMessage(failureReason), + }; + /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { @@ -289,4 +315,59 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https token, apiKeyProvisional); } + + private static bool HasInsufficientQuotaError(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + return false; + + try + { + using var document = JsonDocument.Parse(responseBody); + return HasInsufficientQuotaError(document.RootElement); + } + catch (JsonException) + { + return false; + } + } + + private static bool HasInsufficientQuotaError(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + if (HasJsonStringValue(element, "type", "insufficient_quota") || + HasJsonStringValue(element, "code", "insufficient_quota")) + return true; + + foreach (var property in element.EnumerateObject()) + if (HasInsufficientQuotaError(property.Value)) + return true; + + return false; + + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + if (HasInsufficientQuotaError(item)) + return true; + + return false; + + default: + return false; + } + } + + private static bool IsInsufficientQuota(string? value) + { + return value is not null && value.Equals("insufficient_quota", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasJsonStringValue(JsonElement element, string propertyName, string expectedValue) + { + return element.TryGetProperty(propertyName, out var propertyElement) && + propertyElement.ValueKind is JsonValueKind.String && + string.Equals(propertyElement.GetString(), expectedValue, StringComparison.OrdinalIgnoreCase); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ProviderRequestException.cs b/app/MindWork AI Studio/Provider/ProviderRequestException.cs new file mode 100644 index 00000000..edda3ad5 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ProviderRequestException.cs @@ -0,0 +1,25 @@ +using System.Net; + +namespace AIStudio.Provider; + +public sealed class ProviderRequestException( + ProviderRequestFailureReason failureReason, + string userMessage, + HttpStatusCode? statusCode = null, + string reasonPhrase = "", + string responseBody = "") : Exception(userMessage) +{ + public ProviderRequestException() : this(ProviderRequestFailureReason.NONE, string.Empty) + { + } + + public ProviderRequestFailureReason FailureReason { get; } = failureReason; + + public string UserMessage { get; } = userMessage; + + public HttpStatusCode? StatusCode { get; } = statusCode; + + public string ReasonPhrase { get; } = reasonPhrase; + + public string ResponseBody { get; } = responseBody; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs b/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs new file mode 100644 index 00000000..c56fcc4f --- /dev/null +++ b/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Provider; + +public enum ProviderRequestFailureReason +{ + NONE, + INSUFFICIENT_QUOTA, + TOO_MANY_REQUESTS, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index a79a1341..595a94ef 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -181,7 +181,11 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide using var lmStudioResponse = await this.HttpClient.SendAsync(lmStudioRequest, token); if(!lmStudioResponse.IsSuccessStatusCode) - return FailedModelLoadResult(GetDefaultModelLoadFailureReason(lmStudioResponse), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}"); + { + var responseBody = await lmStudioResponse.Content.ReadAsStringAsync(token); + LOGGER.LogError("Model loading request failed with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", lmStudioResponse.StatusCode, lmStudioResponse.ReasonPhrase, responseBody); + return FailedModelLoadResult(this.GetModelLoadFailureReason(lmStudioResponse, responseBody), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}; Body='{responseBody}'"); + } var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(token); return SuccessfulModelLoadResult(lmStudioModelResponse.Data. diff --git a/app/MindWork AI Studio/Provider/TranscriptionResult.cs b/app/MindWork AI Studio/Provider/TranscriptionResult.cs index 4ee6256a..9e32d9d1 100644 --- a/app/MindWork AI Studio/Provider/TranscriptionResult.cs +++ b/app/MindWork AI Studio/Provider/TranscriptionResult.cs @@ -1,8 +1,8 @@ namespace AIStudio.Provider; -public sealed record TranscriptionResult(bool Success, string Text) +public sealed record TranscriptionResult(bool Success, string Text, string ErrorMessage = "") { public static TranscriptionResult FromText(string text) => new(true, text); - public static TranscriptionResult Failure() => new(false, string.Empty); + public static TranscriptionResult Failure(string errorMessage = "") => new(false, string.Empty, errorMessage); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index 4a6c991d..0fb14711 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -238,6 +238,13 @@ public sealed class AIJobService( { await this.CompleteChatGenerationAsync(state, AIJobStatus.CANCELED); } + catch (ProviderRequestException e) + { + logger.LogError(e, "The provider request failed for chat generation job '{JobId}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", state.Snapshot.JobId, e.StatusCode, e.ReasonPhrase, e.ResponseBody); + RemoveEmptyAIResponse(state); + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.UserMessage); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); + } catch (Exception e) { logger.LogError(e, "The chat generation job '{JobId}' failed.", state.Snapshot.JobId); @@ -270,6 +277,19 @@ public sealed class AIJobService( state.CancellationTokenSource.Dispose(); } + private static void RemoveEmptyAIResponse(AIJobState state) + { + var aiText = state.ChatGenerationRequest.AIText; + if (!string.IsNullOrWhiteSpace(aiText.Text)) + return; + + var aiBlock = state.ChatGenerationRequest.ChatThread.Blocks + .LastOrDefault(block => ReferenceEquals(block.Content, aiText)); + + if (aiBlock is not null) + state.ChatGenerationRequest.ChatThread.Blocks.Remove(aiBlock); + } + private static void UpdateStatus(AIJobState state, AIJobStatus status) { lock (state.SyncRoot) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md index c49ff933..e705efb1 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md @@ -15,6 +15,7 @@ - Fixed an issue where attached documents were detached when editing a previous prompt. They now remain attached. - Fixed an issue where failed transcription requests could be shown as empty transcription results instead of a clear error message. - Fixed an issue where an AI response in chat could be interrupted when you interacted with workspaces, such as opening, closing, or resizing the workspace panel. +- Fixed error messages for provider requests so missing OpenAI API credits and too many requests are shown clearly in chats, assistants, transcription, and model loading. - Fixed missing translations for file type names in file selection dialogs. - Upgraded the native secret storage integration to `keyring-core`, keeping API keys in the secure credential store provided by the operating system. - Upgraded Rust to v1.95.0.