From ec2beaf3ed9342fde25fa7b4d6f8c6fd57e045d9 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 20 Sep 2026 09:38:27 +0200 Subject: [PATCH] Read every provider stream through one server-sent event reader --- .../Provider/BaseProvider.cs | 315 +++++++----------- .../Provider/ServerSentEvent.cs | 13 + 2 files changed, 133 insertions(+), 195 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/ServerSentEvent.cs diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 76541bbb..64b895e5 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -840,19 +840,20 @@ public abstract class BaseProvider : IProvider, ISecretId } /// - /// Streams the chat completion from the provider using the Chat Completion API. + /// Reads a server-sent event stream from the provider, line by line. /// - /// The name of the provider. + /// + /// Everything on the way to a line is here: the retries, the timeouts, the cancellation, and + /// the messages the user gets to see when any of it fails. What a line means is not here -- + /// that differs per wire format, and reading it is the caller's business. + /// + /// The name of the provider, for logging and error reporting. + /// What is being streamed, for logging: a chat completion, say, or a responses call. /// A function that builds the request. /// The cancellation token to use. - /// The type of the delta lines inside the stream. - /// The type of the annotation lines inside the stream. - /// The stream of content chunks. - protected async IAsyncEnumerable StreamChatCompletionInternal(string providerName, Func> requestBuilder, [EnumeratorCancellation] CancellationToken token = default) where TDelta : IResponseStreamLine where TAnnotation : IAnnotationStreamLine + /// The events of the stream, in the order they arrived. + protected async IAsyncEnumerable ReadServerSentEventsAsync(string providerName, string operationName, Func> requestBuilder, [EnumeratorCancellation] CancellationToken token = default) { - // Check if annotations are supported: - var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); - StreamReader? streamReader = null; using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); var timeoutToken = timeoutTokenSource.Token; @@ -862,7 +863,7 @@ public abstract class BaseProvider : IProvider, ISecretId var responseData = await this.SendRequest(requestBuilder, token, timeoutToken); if(responseData.IsFailedAfterAllRetries) { - this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}"); + this.logger.LogError("The {ProviderName} {OperationName} failed: {ErrorMessage}", providerName, operationName, responseData.ErrorMessage); yield break; } @@ -880,108 +881,139 @@ public abstract class BaseProvider : IProvider, ISecretId { if (token.IsCancellationRequested) { - this.logger.LogWarning("The user canceled the chat completion request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName); + this.logger.LogWarning("The user canceled the {OperationName} request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", operationName, providerName, this.InstanceName); } else if (this.IsTimeoutException(e, token)) { await this.SendTimeoutError("opening the chat response stream"); - this.logger.LogError(e, "Timed out while opening the chat completion stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); + this.logger.LogError(e, "Timed out while opening the {OperationName} stream from {ProviderName} '{ProviderInstanceName}'.", operationName, providerName, this.InstanceName); } else { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to stream chat completion from {providerName} '{this.InstanceName}': {e.Message}"); + this.logger.LogError(e, "Failed to stream the {OperationName} from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", operationName, providerName, this.InstanceName, e.Message); } } if (streamReader is null) yield break; - - // - // Read the stream, line by line: - // - while (true) + + try { - try + // + // Read the stream, line by line: + // + while (true) { - if(streamReader.EndOfStream) + try + { + if(streamReader.EndOfStream) + break; + } + catch (Exception e) + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogWarning(e, "Failed to read the end-of-stream state from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", providerName, this.InstanceName, e.Message); break; - } - catch (Exception e) - { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); - break; - } + } - // Check if the token is canceled: - if (token.IsCancellationRequested) - { - this.logger.LogWarning($"The user canceled the chat completion for {providerName} '{this.InstanceName}'."); - streamReader.Close(); - yield break; - } - - // - // Read the next line: - // - string? line; - try - { - line = await streamReader.ReadLineAsync(timeoutToken); - } - catch (Exception e) - { + // Check if the token is canceled: if (token.IsCancellationRequested) { - this.logger.LogWarning("The user canceled the chat completion stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName); - } - else if (this.IsTimeoutException(e, token)) - { - await this.SendTimeoutError("reading the chat response stream"); - this.logger.LogError(e, "Timed out while reading the chat stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); - } - else - { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); + this.logger.LogWarning("The user canceled the {OperationName} for {ProviderName} '{ProviderInstanceName}'.", operationName, providerName, this.InstanceName); + yield break; } - break; + // + // Read the next line: + // + string? line; + try + { + line = await streamReader.ReadLineAsync(timeoutToken); + } + catch (Exception e) + { + if (token.IsCancellationRequested) + { + this.logger.LogWarning("The user canceled the {OperationName} stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", operationName, providerName, this.InstanceName); + } + else if (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("reading the chat response stream"); + this.logger.LogError(e, "Timed out while reading the {OperationName} stream from {ProviderName} '{ProviderInstanceName}'.", operationName, providerName, this.InstanceName); + } + else + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogError(e, "Failed to read the stream from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", providerName, this.InstanceName, e.Message); + } + + break; + } + + if (line is null) + break; + + // Skip empty lines: + if (string.IsNullOrWhiteSpace(line)) + continue; + + if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) + throw providerRequestException; + + // + // Only data lines carry a payload. Every other line goes out as it is, because + // some of them still say something the caller has to act on. + // + TryGetServerSentEventData(line, out var data); + yield return new ServerSentEvent(line, data); } + } + finally + { + streamReader.Dispose(); + } + } - if (line is null) - break; - - // Skip empty lines: - if (string.IsNullOrWhiteSpace(line)) - continue; - - if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) - throw providerRequestException; - - // Skip lines that do not start with "data:". According - // to the specification, we only want to read the data lines: - if (!TryGetServerSentEventData(line, out var jsonData)) + /// + /// Streams the chat completion from the provider using the Chat Completion API. + /// + /// The name of the provider. + /// A function that builds the request. + /// The cancellation token to use. + /// The type of the delta lines inside the stream. + /// The type of the annotation lines inside the stream. + /// The stream of content chunks. + protected async IAsyncEnumerable StreamChatCompletionInternal(string providerName, Func> requestBuilder, [EnumeratorCancellation] CancellationToken token = default) where TDelta : IResponseStreamLine where TAnnotation : IAnnotationStreamLine + { + // Check if annotations are supported: + var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); + + await foreach (var serverSentEvent in this.ReadServerSentEventsAsync(providerName, "chat completion", requestBuilder, token)) + { + // Skip lines without a payload. According to the specification, + // we only want to read the data lines: + if (serverSentEvent.Data.Length is 0) continue; // Check if the line is the end of the stream: - if (jsonData is "[DONE]") + if (serverSentEvent.Data is "[DONE]") yield break; // // Process annotation lines: // - if (annotationSupported && line.Contains(""" - "annotations":[ - """, StringComparison.InvariantCulture)) + if (annotationSupported && serverSentEvent.Line.Contains(""" + "annotations":[ + """, StringComparison.InvariantCulture)) { TAnnotation? providerResponse; try { // Deserialize the JSON data: - providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); + providerResponse = JsonSerializer.Deserialize(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; @@ -1009,7 +1041,7 @@ public abstract class BaseProvider : IProvider, ISecretId try { // Deserialize the JSON data: - providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); + providerResponse = JsonSerializer.Deserialize(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; @@ -1028,8 +1060,6 @@ public abstract class BaseProvider : IProvider, ISecretId yield return providerResponse.GetContent(); } } - - streamReader.Dispose(); } /// @@ -1046,132 +1076,29 @@ public abstract class BaseProvider : IProvider, ISecretId // Check if annotations are supported: var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); - StreamReader? streamReader = null; - using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); - var timeoutToken = timeoutTokenSource.Token; - try + await foreach (var serverSentEvent in this.ReadServerSentEventsAsync(providerName, "responses call", requestBuilder, token)) { - // Send the request using exponential backoff: - var responseData = await this.SendRequest(requestBuilder, token, timeoutToken); - if(responseData.IsFailedAfterAllRetries) - { - this.logger.LogError($"The {providerName} responses call failed: {responseData.ErrorMessage}"); - yield break; - } - - // Open the response stream: - var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken); - - // Add a stream reader to read the stream, line by line: - streamReader = new StreamReader(providerStream); - } - catch(ProviderRequestException) - { - throw; - } - catch(Exception e) - { - if (token.IsCancellationRequested) - { - this.logger.LogWarning("The user canceled the responses request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName); - } - else if (this.IsTimeoutException(e, token)) - { - await this.SendTimeoutError("opening the chat response stream"); - this.logger.LogError(e, "Timed out while opening the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); - } - else - { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to stream responses from {providerName} '{this.InstanceName}': {e.Message}"); - } - } - - if (streamReader is null) - yield break; - - // - // Read the stream, line by line: - // - while (true) - { - try - { - if(streamReader.EndOfStream) - break; - } - catch (Exception e) - { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); - break; - } - - // Check if the token is canceled: - if (token.IsCancellationRequested) - { - this.logger.LogWarning($"The user canceled the responses for {providerName} '{this.InstanceName}'."); - streamReader.Close(); - yield break; - } - - // - // Read the next line: - // - string? line; - try - { - line = await streamReader.ReadLineAsync(timeoutToken); - } - catch (Exception e) - { - if (token.IsCancellationRequested) - { - this.logger.LogWarning("The user canceled the responses stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName); - } - else if (this.IsTimeoutException(e, token)) - { - await this.SendTimeoutError("reading the chat response stream"); - this.logger.LogError(e, "Timed out while reading the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); - } - else - { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); - } - - break; - } - - if (line is null) - break; - - // Skip empty lines: - 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)) + // Check if the line is the end of the stream. This one is read off the raw line + // rather than off a payload, because it has none: + if (serverSentEvent.Line.StartsWith("event: response.completed", StringComparison.InvariantCulture)) yield break; - if (!TryGetServerSentEventData(line, out var jsonData)) + // Skip lines without a payload: + if (serverSentEvent.Data.Length is 0) continue; // // Find delta lines: // - if (jsonData.StartsWith(""" - {"type":"response.output_text.delta" - """, StringComparison.InvariantCulture)) + if (serverSentEvent.Data.StartsWith(""" + {"type":"response.output_text.delta" + """, StringComparison.InvariantCulture)) { TDelta? providerResponse; try { // Deserialize the JSON data: - providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); + providerResponse = JsonSerializer.Deserialize(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; @@ -1193,7 +1120,7 @@ public abstract class BaseProvider : IProvider, ISecretId // // Find annotation added lines: // - else if (annotationSupported && jsonData.StartsWith( + else if (annotationSupported && serverSentEvent.Data.StartsWith( """ {"type":"response.output_text.annotation.added" """, StringComparison.InvariantCulture)) @@ -1202,7 +1129,7 @@ public abstract class BaseProvider : IProvider, ISecretId try { // Deserialize the JSON data: - providerResponse = JsonSerializer.Deserialize(jsonData, JSON_SERIALIZER_OPTIONS); + providerResponse = JsonSerializer.Deserialize(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS); if (providerResponse is null) continue; @@ -1221,8 +1148,6 @@ public abstract class BaseProvider : IProvider, ISecretId yield return new(string.Empty, providerResponse.GetSources()); } } - - streamReader.Dispose(); } /// diff --git a/app/MindWork AI Studio/Provider/ServerSentEvent.cs b/app/MindWork AI Studio/Provider/ServerSentEvent.cs new file mode 100644 index 00000000..b84d4d86 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ServerSentEvent.cs @@ -0,0 +1,13 @@ +namespace AIStudio.Provider; + +/// +/// One event of a server-sent event stream, as it came off the wire. +/// +/// +/// The raw line travels next to its payload because not every decision can be made from the +/// payload alone: the Responses API, for one, ends its stream with an "event:" line which carries +/// no payload at all. +/// +/// The line as it arrived, including its "data:" prefix when it had one. +/// The payload of a data line, empty for every other kind of line. +public readonly record struct ServerSentEvent(string Line, string Data); \ No newline at end of file