From 79a424b065456d79f593aeb66c8ce251cad26bcb Mon Sep 17 00:00:00 2001 From: jaeminbird Date: Thu, 29 Oct 2026 02:34:00 -0400 Subject: [PATCH] Surface usage and finish reason on response.incomplete Fixes #7893 The Responses streaming adapter treated response.incomplete like an in-progress event, so a stream that stopped at max_output_tokens or on a content filter reported no usage and no finish reason. The event is terminal, like response.completed, so map its usage and incomplete reason the same way the non-streaming path does. --- .../OpenAIResponsesChatClient.cs | 8 ++- .../OpenAIResponseClientTests.cs | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index d269d49c5ee..a02a8e21496 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -425,12 +425,18 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) => goto default; case StreamingResponseIncompleteUpdate incompleteUpdate: + { + // response.incomplete is a terminal event sent instead of response.completed, so it carries the usage and the reason the response stopped. createdAt = incompleteUpdate.Response.CreatedAt; responseId = incompleteUpdate.Response.Id; UpdateConversationId(responseId, incompleteUpdate.Response); modelId = incompleteUpdate.Response.Model; latestResponseStatus = incompleteUpdate.Response.Status; - goto default; + var update = CreateUpdate(ToUsageDetails(incompleteUpdate.Response) is { } usage ? new UsageContent(usage) : null); + update.FinishReason = AsFinishReason(incompleteUpdate.Response.IncompleteStatusDetails?.Reason); + yield return update; + break; + } case StreamingResponseFailedUpdate failedUpdate: createdAt = failedUpdate.Response.CreatedAt; diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 3240e40c841..6f6f723e7b8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -5876,6 +5876,55 @@ public async Task StreamingResponseWithIncompleteUpdate_HandlesCorrectly() Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); } + [Fact] + public async Task StreamingResponseWithIncompleteUpdate_SurfacesFinishReasonAndUsage() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_001","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_001","output_index":0,"content_index":0,"delta":"Partial"} + + event: response.incomplete + data: {"type":"response.incomplete","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"incomplete","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"incomplete","role":"assistant","content":[{"type":"output_text","text":"Partial","annotations":[]}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":12,"input_tokens_details":{"cached_tokens":0},"output_tokens":16,"output_tokens_details":{"reasoning_tokens":10},"total_tokens":28}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.Equal(ChatFinishReason.Length, updates[updates.Count - 1].FinishReason); + + ChatResponse response = updates.ToChatResponse(); + Assert.Equal("Partial", response.Text); + Assert.Equal(ChatFinishReason.Length, response.FinishReason); + Assert.NotNull(response.Usage); + Assert.Equal(12, response.Usage.InputTokenCount); + Assert.Equal(16, response.Usage.OutputTokenCount); + Assert.Equal(10, response.Usage.ReasoningTokenCount); + Assert.Equal(28, response.Usage.TotalTokenCount); + } + [Fact] public async Task StreamingResponseWithInProgressUpdate_HandlesCorrectly() {