using System.Text.Json; namespace AIStudio.Provider.OpenAI; /// /// Reads a streamed Responses API call back into the response the tool calling loop works with. /// /// /// The API repeats the whole response when it is done, reasoning items included, so nothing has /// to be reassembled from fragments: that closing event is the round. What this type does beyond /// taking it is hand out text and sources while they arrive, and keep the finished output items /// as a fallback for gateways which never send that closing event.

/// No HTTP, no dependency injection, no provider: everything here is a decision about bytes, and /// those are the decisions worth having a test for. ///
public sealed class ResponsesStreamAccumulator { private const string EVENT_COMPLETED = "response.completed"; private const string EVENT_TEXT_DELTA = "response.output_text.delta"; private const string EVENT_ANNOTATION_ADDED = "response.output_text.annotation.added"; private const string EVENT_OUTPUT_ITEM_DONE = "response.output_item.done"; private readonly List completedOutputItems = []; private ResponsesResponse? completedResponse; /// /// Takes the next event of the stream and returns what it has to show. /// /// The event to read. /// The text and sources of this event, both empty when it carried neither. public ResponsesStreamPart Process(ServerSentEvent serverSentEvent) { if (serverSentEvent.Data.Length is 0) return ResponsesStreamPart.Nothing; string eventType; try { using var document = JsonDocument.Parse(serverSentEvent.Data); var root = document.RootElement; if (root.ValueKind is not JsonValueKind.Object || !root.TryGetProperty("type", out var typeProperty) || typeProperty.ValueKind is not JsonValueKind.String) return ResponsesStreamPart.Nothing; eventType = typeProperty.GetString() ?? string.Empty; // // The item is cloned because its document is disposed at the end of this block, and // an element which outlives its document reads memory that is no longer there. // if (eventType is EVENT_OUTPUT_ITEM_DONE && root.TryGetProperty("item", out var outputItem)) this.completedOutputItems.Add(outputItem.Clone()); } catch (JsonException) { // A line we cannot read is a line we skip, exactly as the plain text path does: return ResponsesStreamPart.Nothing; } switch (eventType) { case EVENT_COMPLETED: this.completedResponse = TryDeserialize(serverSentEvent.Data)?.Response ?? this.completedResponse; return ResponsesStreamPart.Nothing; case EVENT_TEXT_DELTA: var deltaLine = TryDeserialize(serverSentEvent.Data); if (deltaLine is null || !deltaLine.ContainsContent()) return ResponsesStreamPart.Nothing; return new ResponsesStreamPart(deltaLine.GetContent().Content, []); case EVENT_ANNOTATION_ADDED: var annotationLine = TryDeserialize(serverSentEvent.Data); if (annotationLine is null || !annotationLine.ContainsSources()) return ResponsesStreamPart.Nothing; return new ResponsesStreamPart(string.Empty, annotationLine.GetSources()); default: return ResponsesStreamPart.Nothing; } } /// /// Builds the response of the round from everything the stream said. /// /// /// The response, or null when the stream ended before it said anything usable. Null is how a /// failed request and a truncated stream look from here, and both end the round. /// public ResponsesResponse? Build() { if (this.completedResponse is not null) return this.completedResponse; if (this.completedOutputItems.Count is 0) return null; // // No closing event came, so the round is put back together from the items which did. // Reasoning items are among them, which is what the next request needs to continue. // return new ResponsesResponse { Output = [..this.completedOutputItems], }; } private static T? TryDeserialize(string json) where T : class { try { return JsonSerializer.Deserialize(json, ProviderJsonOptions.OPTIONS); } catch (JsonException) { return null; } } }