Skip to content

Commit d62ee42

Browse files
Add reasoning text content for OpenAI Responses ChatClient Streaming (#6761)
* Add reasoning text content for OpenAI Responses ChatClient Streaming --------- Co-authored-by: Stephen Toub <[email protected]>
1 parent 20349f8 commit d62ee42

File tree

2 files changed

+195
-0
lines changed

2 files changed

+195
-0
lines changed

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.ClientModel.Primitives;
66
using System.Collections.Generic;
7+
using System.Diagnostics.CodeAnalysis;
78
using System.Linq;
89
using System.Reflection;
910
using System.Runtime.CompilerServices;
@@ -26,6 +27,11 @@ namespace Microsoft.Extensions.AI;
2627
/// <summary>Represents an <see cref="IChatClient"/> for an <see cref="OpenAIResponseClient"/>.</summary>
2728
internal sealed class OpenAIResponsesChatClient : IChatClient
2829
{
30+
// Fix this to not use reflection once https://github.com/openai/openai-dotnet/issues/643 is addressed.
31+
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
32+
private static readonly Type? _internalResponseReasoningSummaryTextDeltaEventType = Type.GetType("OpenAI.Responses.InternalResponseReasoningSummaryTextDeltaEvent, OpenAI");
33+
private static readonly PropertyInfo? _summaryTextDeltaProperty = _internalResponseReasoningSummaryTextDeltaEventType?.GetProperty("Delta");
34+
2935
/// <summary>Metadata about the client.</summary>
3036
private readonly ChatClientMetadata _metadata;
3137

@@ -324,8 +330,17 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
324330
break;
325331

326332
default:
333+
{
334+
if (streamingUpdate.GetType() == _internalResponseReasoningSummaryTextDeltaEventType &&
335+
_summaryTextDeltaProperty?.GetValue(streamingUpdate) is string delta)
336+
{
337+
yield return CreateUpdate(new TextReasoningContent(delta));
338+
break;
339+
}
340+
327341
yield return CreateUpdate();
328342
break;
343+
}
329344
}
330345
}
331346
}

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,186 @@ public async Task BasicRequestResponse_NonStreaming()
168168
Assert.Equal(36, response.Usage.TotalTokenCount);
169169
}
170170

171+
[Fact]
172+
public async Task BasicReasoningResponse_Streaming()
173+
{
174+
const string Input = """
175+
{
176+
"input":[{
177+
"type":"message",
178+
"role":"user",
179+
"content":[{
180+
"type":"input_text",
181+
"text":"Calculate the sum of the first 5 positive integers."
182+
}]
183+
}],
184+
"reasoning": {
185+
"summary": "detailed",
186+
"effort": "low"
187+
},
188+
"model": "o4-mini",
189+
"stream": true
190+
}
191+
""";
192+
193+
// Compressed down for testing purposes; real-world output would be larger.
194+
const string Output = """
195+
event: response.created
196+
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
197+
198+
event: response.in_progress
199+
data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
200+
201+
event: response.output_item.added
202+
data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[]}}
203+
204+
event: response.reasoning_summary_part.added
205+
data: {"type":"response.reasoning_summary_part.added","sequence_number":3,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}
206+
207+
event: response.reasoning_summary_text.delta
208+
data: {"type":"response.reasoning_summary_text.delta","sequence_number":4,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":"**Calcul","obfuscation":"sLkbFySM"}
209+
210+
event: response.reasoning_summary_text.delta
211+
data: {"type":"response.reasoning_summary_text.delta","sequence_number":5,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":"ating","obfuscation":"dkm1f6DKqUj"}
212+
213+
event: response.reasoning_summary_text.delta
214+
data: {"type":"response.reasoning_summary_text.delta","sequence_number":6,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" a","obfuscation":"X8ahc2lfCf9eA1"}
215+
216+
event: response.reasoning_summary_text.delta
217+
data: {"type":"response.reasoning_summary_text.delta","sequence_number":7,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" simple","obfuscation":"1rLVyIaNl"}
218+
219+
event: response.reasoning_summary_text.delta
220+
data: {"type":"response.reasoning_summary_text.delta","sequence_number":8,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" sum","obfuscation":"jCK7mgNR80Re"}
221+
222+
event: response.reasoning_summary_text.done
223+
data: {"type":"response.reasoning_summary_text.done","sequence_number":9,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"text":"**Calculating a simple sum**"}
224+
225+
event: response.reasoning_summary_part.done
226+
data: {"type":"response.reasoning_summary_part.done","sequence_number":10,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":"**Calculating a simple sum**"}}
227+
228+
event: response.output_item.done
229+
data: {"type":"response.output_item.done","sequence_number":11,"output_index":0,"item":{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[{"type":"summary_text","text":"**Calculating a simple sum**"}]}}
230+
231+
event: response.output_item.added
232+
data: {"type":"response.output_item.added","sequence_number":12,"output_index":1,"item":{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"in_progress","content":[],"role":"assistant"}}
233+
234+
event: response.content_part.added
235+
data: {"type":"response.content_part.added","sequence_number":13,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}
236+
237+
event: response.output_text.delta
238+
data: {"type":"response.output_text.delta","sequence_number":14,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":"The","logprobs":[],"obfuscation":"japg2KaCkjNsp"}
239+
240+
event: response.output_text.delta
241+
data: {"type":"response.output_text.delta","sequence_number":15,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" sum","logprobs":[],"obfuscation":"1BEqjKQ0KU41"}
242+
243+
event: response.output_text.delta
244+
data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" of","logprobs":[],"obfuscation":"GUqom1rsdZsnT"}
245+
246+
event: response.output_text.delta
247+
data: {"type":"response.output_text.delta","sequence_number":17,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"UmCms91yrTlg"}
248+
249+
event: response.output_text.delta
250+
data: {"type":"response.output_text.delta","sequence_number":18,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" first","logprobs":[],"obfuscation":"AyNbZpfTXo"}
251+
252+
event: response.output_text.delta
253+
data: {"type":"response.output_text.delta","sequence_number":19,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"tuyz4HkKODFQRtk"}
254+
255+
event: response.output_text.delta
256+
data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":"5","logprobs":[],"obfuscation":"QAwyISolmjXfTlc"}
257+
258+
event: response.output_text.delta
259+
data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" positive","logprobs":[],"obfuscation":"2Euge1H"}
260+
261+
event: response.output_text.delta
262+
data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" integers","logprobs":[],"obfuscation":"ih0Znt8"}
263+
264+
event: response.output_text.delta
265+
data: {"type":"response.output_text.delta","sequence_number":23,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" is","logprobs":[],"obfuscation":"oQihR5Pw8jRz5"}
266+
267+
event: response.output_text.delta
268+
data: {"type":"response.output_text.delta","sequence_number":24,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" 15","logprobs":[],"obfuscation":"7TdJ1FWlZF8lTd"}
269+
270+
event: response.output_text.delta
271+
data: {"type":"response.output_text.delta","sequence_number":25,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":".","logprobs":[],"obfuscation":"x2VAJKlWI8qjgYq"}
272+
273+
event: response.output_text.done
274+
data: {"type":"response.output_text.done","sequence_number":26,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"text":"The sum of the first 5 positive integers is 15.","logprobs":[]}
275+
276+
event: response.content_part.done
277+
data: {"type":"response.content_part.done","sequence_number":27,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}}
278+
279+
event: response.output_item.done
280+
data: {"type":"response.output_item.done","sequence_number":28,"output_index":1,"item":{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}],"role":"assistant"}}
281+
282+
event: response.completed
283+
data: {"type":"response.completed","sequence_number":29,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[{"type":"summary_text","text":"**Calculating a simple sum**"}]},{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":17,"input_tokens_details":{"cached_tokens":0},"output_tokens":122,"output_tokens_details":{"reasoning_tokens":64},"total_tokens":139},"user":null,"metadata":{}}}
284+
285+
286+
""";
287+
288+
using VerbatimHttpHandler handler = new(Input, Output);
289+
using HttpClient httpClient = new(handler);
290+
using IChatClient client = CreateResponseClient(httpClient, "o4-mini");
291+
292+
List<ChatResponseUpdate> updates = [];
293+
await foreach (var update in client.GetStreamingResponseAsync("Calculate the sum of the first 5 positive integers.", new()
294+
{
295+
RawRepresentationFactory = options => new ResponseCreationOptions
296+
{
297+
ReasoningOptions = new()
298+
{
299+
ReasoningEffortLevel = ResponseReasoningEffortLevel.Low,
300+
ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Detailed
301+
}
302+
}
303+
}))
304+
{
305+
updates.Add(update);
306+
}
307+
308+
Assert.Equal("The sum of the first 5 positive integers is 15.", string.Concat(updates.Select(u => u.Text)));
309+
310+
var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_756_752_811);
311+
Assert.Equal(30, updates.Count);
312+
313+
for (int i = 0; i < updates.Count; i++)
314+
{
315+
Assert.Equal("resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704", updates[i].ResponseId);
316+
Assert.Equal("resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704", updates[i].ConversationId);
317+
Assert.Equal(createdAt, updates[i].CreatedAt);
318+
Assert.Equal("o4-mini-2025-04-16", updates[i].ModelId);
319+
Assert.Null(updates[i].AdditionalProperties);
320+
321+
if (i is (>= 4 and <= 8))
322+
{
323+
// Reasoning updates
324+
Assert.Single(updates[i].Contents);
325+
Assert.Null(updates[i].Role);
326+
327+
var reasoning = Assert.IsType<TextReasoningContent>(updates[i].Contents.Single());
328+
Assert.NotNull(reasoning);
329+
Assert.NotNull(reasoning.Text);
330+
}
331+
else if (i is (>= 14 and <= 25) or 29)
332+
{
333+
// Response Complete and Assistant message updates
334+
Assert.Single(updates[i].Contents);
335+
}
336+
else
337+
{
338+
// Other updates
339+
Assert.Empty(updates[i].Contents);
340+
}
341+
342+
Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Stop, updates[i].FinishReason);
343+
}
344+
345+
UsageContent usage = updates.SelectMany(u => u.Contents).OfType<UsageContent>().Single();
346+
Assert.Equal(17, usage.Details.InputTokenCount);
347+
Assert.Equal(122, usage.Details.OutputTokenCount);
348+
Assert.Equal(139, usage.Details.TotalTokenCount);
349+
}
350+
171351
[Fact]
172352
public async Task BasicRequestResponse_Streaming()
173353
{

0 commit comments

Comments
 (0)