Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions EssentialCSharp.Chat.Shared/Services/AIChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ private static string SanitizeForXmlContext(string? input) =>
string? currentLegResponseId = null;
var textPartsWithDelta = new HashSet<string>(StringComparer.Ordinal);
var refusalPartsWithDelta = new HashSet<string>(StringComparer.Ordinal);
var streamUpdateTypes = new List<string>(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<FunctionCallResponseItem>? pendingFunctionCalls = null;

Expand All @@ -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
Expand Down Expand Up @@ -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.
}
Expand Down Expand Up @@ -652,6 +683,15 @@ private static string BuildStreamingTerminalFailureMessage(ResponseResult respon

return $"Streaming response ended with status '{terminalStatus}'.";
}

private static string BuildRecentUpdateSequence(List<string> 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}")]
Expand All @@ -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);

/// <summary>
/// Returns <c>true</c> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 6 additions & 6 deletions EssentialCSharp.Chat.Shared/Services/LocalChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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);
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions EssentialCSharp.Web/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading