diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs index fc103f43..2b5ba2d7 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs @@ -8,6 +8,7 @@ using CrestApps.Core.AI.Exceptions; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Resilience; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.ResponseHandling; using CrestApps.Core.AI.Security; @@ -175,6 +176,13 @@ protected virtual string GetFriendlyErrorMessage(Exception ex) return GetInvalidChatModelSettingsMessage(); } + var providerDetail = AIProviderErrorHelper.TryExtractProviderMessage(ex); + + if (!string.IsNullOrWhiteSpace(providerDetail)) + { + return $"The AI model rejected the request: {providerDetail}"; + } + return "An error occurred processing your message."; } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs index b6ce79ac..f898e55f 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs @@ -6,6 +6,7 @@ using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Resilience; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.ResponseHandling; using CrestApps.Core.AI.Security; @@ -225,6 +226,13 @@ protected virtual string GetFriendlyErrorMessage(Exception ex) return GetInvalidChatModelSettingsMessage(); } + var providerDetail = AIProviderErrorHelper.TryExtractProviderMessage(ex); + + if (!string.IsNullOrWhiteSpace(providerDetail)) + { + return $"The AI model rejected the request: {providerDetail}"; + } + return "An error occurred while processing your message."; } diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs index db8f7b45..cbcce3ff 100644 --- a/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs +++ b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs @@ -3,6 +3,7 @@ using CrestApps.Core.AI.Handlers; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Resilience; using CrestApps.Core.AI.Tooling; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -132,7 +133,12 @@ public async IAsyncEnumerable ExecuteStreamingAsync( catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "ClaudeOrchestrator: Unexpected error during Anthropic session."); - errorResponse = CreateTextResponse("An unexpected error occurred while communicating with Anthropic. Please try again."); + var providerDetail = AIProviderErrorHelper.TryExtractProviderMessage(ex); + + errorResponse = string.IsNullOrWhiteSpace(providerDetail) + ? CreateTextResponse("An unexpected error occurred while communicating with Anthropic. Please try again.") + : CreateTextResponse($"Anthropic returned an error: {providerDetail}"); + break; } diff --git a/src/Primitives/CrestApps.Core.AI.Resilience/AIProviderErrorHelper.cs b/src/Primitives/CrestApps.Core.AI.Resilience/AIProviderErrorHelper.cs index 246872fd..c4282f56 100644 --- a/src/Primitives/CrestApps.Core.AI.Resilience/AIProviderErrorHelper.cs +++ b/src/Primitives/CrestApps.Core.AI.Resilience/AIProviderErrorHelper.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text.Json; namespace CrestApps.Core.AI.Resilience; @@ -8,6 +9,7 @@ namespace CrestApps.Core.AI.Resilience; public static class AIProviderErrorHelper { private const string ClientResultExceptionName = "ClientResultException"; + private const string RequestFailedExceptionName = "RequestFailedException"; private static readonly string[] _rateLimitIndicators = ["ratelimitreached", "rate limit", "too many requests"]; @@ -57,7 +59,8 @@ public static bool IsRateLimitException(Exception ex) } var type = ex.GetType(); - if (!string.Equals(type.Name, ClientResultExceptionName, StringComparison.Ordinal)) + if (!string.Equals(type.Name, ClientResultExceptionName, StringComparison.Ordinal) + && !string.Equals(type.Name, RequestFailedExceptionName, StringComparison.Ordinal)) { return null; } @@ -78,6 +81,70 @@ public static bool IsRateLimitException(Exception ex) return null; } + /// + /// Extracts the most specific human-readable error message from any AI provider exception. + /// Works for OpenAI (ClientResultException), Azure (RequestFailedException), + /// and any other provider that surfaces errors via . + /// + /// The exception to inspect. + /// The extracted message, or when none can be determined. + public static string TryExtractProviderMessage(Exception ex) + { + if (ex is null) + { + return null; + } + + foreach (var currentException in EnumerateExceptions(ex)) + { + if (!string.Equals(currentException.GetType().Name, ClientResultExceptionName, StringComparison.Ordinal) && !string.Equals(currentException.GetType().Name, RequestFailedExceptionName, StringComparison.Ordinal)) + { + continue; + } + + // Prefer the structured error.message from the JSON body when present. + var jsonBodyIndex = currentException.Message?.LastIndexOf('{') ?? -1; + + if (jsonBodyIndex >= 0) + { + try + { + using var logs = JsonDocument.Parse(currentException.Message.Substring(jsonBodyIndex)); + + var errorBody = logs.RootElement; + + // OpenAI / Azure OpenAI / Anthropic: {"error":{"message":"..."}} + if (errorBody.TryGetProperty("error", out var errorNode) && errorNode.TryGetProperty("message", out var errorMessage)) + { + return errorMessage.GetString(); + } + + // Azure AI Inference / generic: {"message":"..."} + if (errorBody.TryGetProperty("message", out var directMessage)) + { + return directMessage.GetString(); + } + } + catch (JsonException) + { + // Not valid JSON — fall through to raw message. + } + } + + if (!string.IsNullOrWhiteSpace(currentException.Message)) + { + var rawMessage = currentException.Message.TrimEnd(); + var lastNewLineIndex = rawMessage.LastIndexOfAny(['\n', '\r']); + var lastLine = lastNewLineIndex >= 0 ? rawMessage.Substring(lastNewLineIndex + 1).Trim() : rawMessage.Trim(); + var firstPeriodIndex = lastLine.IndexOf('.'); + + return firstPeriodIndex >= 0 ? lastLine.Substring(0, firstPeriodIndex + 1) : lastLine; + } + } + + return null; + } + /// /// Determines whether the specified message contains a rate-limit indicator. /// diff --git a/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs b/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs index f4d243ef..0afe3e34 100644 --- a/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs +++ b/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs @@ -30,6 +30,14 @@ public static LocalizedString GetFriendlyErrorMessage(Exception ex, IStringLocal ? S["Rate limit reached. Please wait and try again later."] : S["Rate limit reached. {0}", retryAfterMessage]; } + else if (clientStatusCode == (int)HttpStatusCode.BadRequest) + { + var providerDetail = AIProviderErrorHelper.TryExtractProviderMessage(ex); + + return string.IsNullOrWhiteSpace(providerDetail) + ? S["Invalid request. Please verify your connection settings."] + : S["The AI model rejected the request: {0}", providerDetail]; + } if (ex is HttpRequestException httpEx) {