diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index bf308e37..1ff9c61a 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -194,6 +194,7 @@ private static string SanitizeForXmlContext(string? input) => string? currentLegResponseId = null; var textPartsWithDelta = new HashSet(StringComparer.Ordinal); var refusalPartsWithDelta = new HashSet(StringComparer.Ordinal); + var streamUpdateTypes = new List(capacity: 64); #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. List? pendingFunctionCalls = null; @@ -202,6 +203,9 @@ private static string SanitizeForXmlContext(string? input) => // separate helper that puts try/catch only around MoveNextAsync. await foreach (var update in RethrowContextLengthErrors(streamingUpdates, responseOptions.PreviousResponseId, cancellationToken)) { + var updateType = update.GetType().Name; + streamUpdateTypes.Add(updateType); + if (update is StreamingResponseCreatedUpdate created) { // Emit the response ID early so the controller can record ownership @@ -250,18 +254,45 @@ private static string SanitizeForXmlContext(string? input) => } else if (update is StreamingResponseErrorUpdate errorUpdate) { + LogStreamingResponseErrorUpdate( + _Logger, + currentLegResponseId, + errorUpdate.Code ?? "unknown", + errorUpdate.Message ?? "no message provided", + BuildRecentUpdateSequence(streamUpdateTypes)); throw new ChatBackendUnavailableException( - $"Streaming response error: {errorUpdate.Code ?? "unknown"} - {errorUpdate.Message ?? "no message provided"}"); + $"Streaming response error: {errorUpdate.Code ?? "unknown"} - {errorUpdate.Message ?? "no message provided"}", + errorCode: "stream_response_error"); } else if (update is StreamingResponseFailedUpdate failedUpdate) { + LogStreamingResponseTerminalUpdate( + _Logger, + "failed", + failedUpdate.Response.Id, + failedUpdate.Response.Status?.ToString(), + failedUpdate.Response.Error?.Code.ToString(), + failedUpdate.Response.Error?.Message, + failedUpdate.Response.IncompleteStatusDetails?.Reason?.ToString(), + BuildRecentUpdateSequence(streamUpdateTypes)); throw new ChatBackendUnavailableException( - BuildStreamingTerminalFailureMessage(failedUpdate.Response, "failed")); + BuildStreamingTerminalFailureMessage(failedUpdate.Response, "failed"), + errorCode: "stream_response_failed"); } else if (update is StreamingResponseIncompleteUpdate incompleteUpdate) { + LogStreamingResponseTerminalUpdate( + _Logger, + "incomplete", + incompleteUpdate.Response.Id, + incompleteUpdate.Response.Status?.ToString(), + incompleteUpdate.Response.Error?.Code.ToString(), + incompleteUpdate.Response.Error?.Message, + incompleteUpdate.Response.IncompleteStatusDetails?.Reason?.ToString(), + BuildRecentUpdateSequence(streamUpdateTypes)); throw new ChatBackendUnavailableException( - BuildStreamingTerminalFailureMessage(incompleteUpdate.Response, "incomplete")); + BuildStreamingTerminalFailureMessage(incompleteUpdate.Response, "incomplete"), + errorCode: "stream_response_incomplete"); } // StreamingResponseCompletedUpdate: ResponseId already emitted above — no-op. } @@ -652,6 +683,15 @@ private static string BuildStreamingTerminalFailureMessage(ResponseResult respon return $"Streaming response ended with status '{terminalStatus}'."; } + + private static string BuildRecentUpdateSequence(List streamUpdateTypes) + { + const int MaxUpdatesToInclude = 40; + int count = streamUpdateTypes.Count; + if (count <= MaxUpdatesToInclude) + return string.Join(" -> ", streamUpdateTypes); + return $"... -> {string.Join(" -> ", streamUpdateTypes.Skip(count - MaxUpdatesToInclude))}"; + } #pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. [LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked: tool={ToolName} iteration={Iteration} user={EndUserId}")] @@ -672,6 +712,29 @@ private static string BuildStreamingTerminalFailureMessage(ResponseResult respon [LoggerMessage(Level = LogLevel.Information, Message = "AI contextual search performed for prompt enrichment")] private static partial void LogContextualSearchPerformed(ILogger logger); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Streaming response error update received: responseId={ResponseId} code={Code} message={Message} updateSequence={UpdateSequence}")] + private static partial void LogStreamingResponseErrorUpdate( + ILogger logger, + string? responseId, + string code, + string message, + string updateSequence); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Streaming response terminal update: updateType={UpdateType} responseId={ResponseId} status={Status} errorCode={ErrorCode} errorMessage={ErrorMessage} incompleteReason={IncompleteReason} updateSequence={UpdateSequence}")] + private static partial void LogStreamingResponseTerminalUpdate( + ILogger logger, + string updateType, + string? responseId, + string? status, + string? errorCode, + string? errorMessage, + string? incompleteReason, + string updateSequence); + /// /// Returns true when the API error indicates the conversation context window was exceeded. /// Prefers structured JSON error code from the response body; falls back to message text matching. diff --git a/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs index 61346060..d3ee1b28 100644 --- a/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs +++ b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs @@ -1,4 +1,15 @@ namespace EssentialCSharp.Chat.Common.Services; -public class ChatBackendUnavailableException(string message, Exception? innerException = null) - : Exception(message, innerException); +public class ChatBackendUnavailableException : Exception +{ + public string ErrorCode { get; } + + public ChatBackendUnavailableException( + string message, + string errorCode = "chat_unavailable", + Exception? innerException = null) + : base(message, innerException) + { + ErrorCode = string.IsNullOrWhiteSpace(errorCode) ? "chat_unavailable" : errorCode; + } +} diff --git a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs index f607082d..7be84a1e 100644 --- a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs @@ -71,11 +71,11 @@ public void Dispose() } catch (HttpRequestException ex) { - throw new ChatBackendUnavailableException("Local AI backend is unavailable.", ex); + throw new ChatBackendUnavailableException("Local AI backend is unavailable.", innerException: ex); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - throw new ChatBackendUnavailableException("Local AI backend timed out.", ex); + throw new ChatBackendUnavailableException("Local AI backend timed out.", innerException: ex); } using (response) @@ -87,15 +87,15 @@ public void Dispose() } catch (HttpRequestException ex) { - throw new ChatBackendUnavailableException("Local AI backend is unavailable while reading response.", ex); + throw new ChatBackendUnavailableException("Local AI backend is unavailable while reading response.", innerException: ex); } catch (IOException ex) { - throw new ChatBackendUnavailableException("Local AI backend connection closed while reading response.", ex); + throw new ChatBackendUnavailableException("Local AI backend connection closed while reading response.", innerException: ex); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - throw new ChatBackendUnavailableException("Local AI backend timed out while reading response.", ex); + throw new ChatBackendUnavailableException("Local AI backend timed out while reading response.", innerException: ex); } if (!response.IsSuccessStatusCode) @@ -117,7 +117,7 @@ public void Dispose() } catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException) { - throw new ChatBackendUnavailableException("Local AI backend returned an invalid response.", ex); + throw new ChatBackendUnavailableException("Local AI backend returned an invalid response.", innerException: ex); } } } diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 7cfcb636..9c3b6ad7 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -278,14 +278,15 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); Response.StatusCode = StatusCodes.Status503ServiceUnavailable; Response.ContentType = "application/json"; - await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = "chat_unavailable" }, cancellationToken); + await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = ex.ErrorCode }, cancellationToken); } catch (ChatBackendUnavailableException ex) { LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); try { - await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"Chat service unavailable\",\"errorCode\":\"chat_unavailable\"}\n\n", CancellationToken.None); + var eventData = JsonSerializer.Serialize(new { type = "error", message = "Chat service unavailable", errorCode = ex.ErrorCode }); + await Response.WriteAsync($"data: {eventData}\n\n", CancellationToken.None); await Response.Body.FlushAsync(CancellationToken.None); } catch (Exception writeException) when (writeException is IOException or OperationCanceledException or ObjectDisposedException)