66using System . Runtime . InteropServices ;
77#endif
88using System . Text ;
9- using System . Threading . Tasks ;
109using Microsoft . Shared . Diagnostics ;
1110
11+ #pragma warning disable S109 // Magic numbers should not be used
1212#pragma warning disable S127 // "for" loop stop conditions should be invariant
1313
1414namespace Microsoft . Extensions . AI ;
@@ -18,79 +18,17 @@ namespace Microsoft.Extensions.AI;
1818/// </summary>
1919public static class StreamingChatCompletionUpdateExtensions
2020{
21- /// <summary>
22- /// Augments an <see cref="IAsyncEnumerable{StreamingChatCompletionUpdate}"/> produced by
23- /// <see cref="IChatClient.CompleteStreamingAsync"/> to add a merged message to the list of messages
24- /// upon completion of iterating through the enumerable.
25- /// </summary>
26- /// <param name="updates">The list of updates to coalesce into a message added to <paramref name="chatMessages"/>.</param>
27- /// <param name="chatMessages">The list of messages to which the chat completion message should be added.</param>
21+ /// <summary>Combines <see cref="StreamingChatCompletionUpdate"/> instances into a single <see cref="ChatCompletion"/>.</summary>
22+ /// <param name="updates">The updates to be combined.</param>
2823 /// <param name="coalesceContent">
29- /// <see langword="true"/> to attempt to coalesce contiguous streaming update <see cref="AIContent"/> items, where applicable,
24+ /// <see langword="true"/> to attempt to coalesce contiguous <see cref="AIContent"/> items, where applicable,
3025 /// into a single <see cref="AIContent"/>, in order to reduce the number of individual content items that are included in
31- /// manufactured <see cref="ChatMessage"/> instances. When <see langword="false"/>, the content items are kept unaltered.
32- /// A value of <see langword="true"/> does not impact the updates yielded from the enumerable, only the final message added
33- /// to <paramref name="chatMessages"/>.
26+ /// the manufactured <see cref="ChatMessage"/> instances. When <see langword="false"/>, the original content items are used.
27+ /// The default is <see langword="true"/>.
3428 /// </param>
35- /// <returns>
36- /// An enumerable that yields the same updates as <paramref name="updates"/>. It must be enumerated to completion
37- /// or disposed to ensure that the final message is added to <paramref name="chatMessages"/>.
38- /// </returns>
39- /// <remarks>
40- /// <para>
41- /// If any updates were yielded, the method will add a message to <paramref name="chatMessages"/> upon
42- /// completion of iteration, regardless of whether completion occurs due to exhausting the input enumerable,
43- /// due to an exception ocurring, or due to early disposal of the enumerator. Each iteration through the
44- /// returned enumerable may possibly add a message, so it should typically only be enumerated once.
45- /// </para>
46- /// <para>
47- /// At most one <see cref="ChatMessage"/> will be added to <paramref name="chatMessages"/>. If the supplied updates
48- /// represent multiple choices, only one choice will be added.
49- /// </para>
50- /// </remarks>
51- public static IAsyncEnumerable < StreamingChatCompletionUpdate > WithMessageAddedAsync (
52- this IAsyncEnumerable < StreamingChatCompletionUpdate > updates ,
53- IList < ChatMessage > chatMessages ,
54- bool coalesceContent = true )
55- {
56- _ = Throw . IfNull ( updates ) ;
57- _ = Throw . IfNull ( chatMessages ) ;
58-
59- return ImplAsync ( updates , chatMessages , coalesceContent ) ;
60-
61- static async IAsyncEnumerable < StreamingChatCompletionUpdate > ImplAsync (
62- IAsyncEnumerable < StreamingChatCompletionUpdate > updates ,
63- IList < ChatMessage > chatMessages ,
64- bool coalesceContent = true )
65- {
66- List < StreamingChatCompletionUpdate > updatesList = [ ] ;
67- try
68- {
69- await foreach ( var update in updates . ConfigureAwait ( false ) )
70- {
71- updatesList . Add ( update ) ;
72- yield return update ;
73- }
74- }
75- finally
76- {
77- if ( coalesceContent )
78- {
79- CoalesceTextContent ( updatesList ) ;
80- }
81-
82- if ( ToChatCompletion ( updatesList ) is { Choices . Count : > 0 } completion )
83- {
84- chatMessages . Add ( completion . Message ) ;
85- }
86- }
87- }
88- }
89-
90- /// <summary>Combines <see cref="StreamingChatCompletionUpdate"/> instances into a single <see cref="ChatCompletion"/>.</summary>
91- /// <param name="updates">The updates to be combined.</param>
9229 /// <returns>The combined <see cref="ChatCompletion"/>.</returns>
93- public static ChatCompletion ToChatCompletion ( this IEnumerable < StreamingChatCompletionUpdate > updates )
30+ public static ChatCompletion ToChatCompletion (
31+ this IEnumerable < StreamingChatCompletionUpdate > updates , bool coalesceContent = true )
9432 {
9533 _ = Throw . IfNull ( updates ) ;
9634
@@ -146,6 +84,11 @@ public static ChatCompletion ToChatCompletion(this IEnumerable<StreamingChatComp
14684 entry . Value . Role = ChatRole . Assistant ;
14785 }
14886
87+ if ( coalesceContent )
88+ {
89+ CoalesceTextContent ( ( List < AIContent > ) entry . Value . Contents ) ;
90+ }
91+
14992 completion . Choices . Add ( entry . Value ) ;
15093
15194 if ( completion . Usage is null )
@@ -164,103 +107,51 @@ public static ChatCompletion ToChatCompletion(this IEnumerable<StreamingChatComp
164107 return completion ;
165108 }
166109
167- /// <summary>Coalesces sequential <see cref="TextContent"/> updates .</summary>
168- internal static void CoalesceTextContent ( this List < StreamingChatCompletionUpdate > updates )
110+ /// <summary>Coalesces sequential <see cref="TextContent"/> content elements .</summary>
111+ private static void CoalesceTextContent ( List < AIContent > contents )
169112 {
170- StringBuilder coalescedText = new ( ) ;
113+ StringBuilder ? coalescedText = null ;
171114
172115 // Iterate through all of the items in the list looking for contiguous items that can be coalesced.
173- for ( int startInclusive = 0 ; startInclusive < updates . Count ; startInclusive ++ )
116+ int start = 0 ;
117+ while ( start < contents . Count - 1 )
174118 {
175- // If an item isn't generally coalescable, skip it.
176- StreamingChatCompletionUpdate update = updates [ startInclusive ] ;
177- if ( update . ChoiceIndex != 0 ||
178- update . Contents . Count != 1 ||
179- update . Contents [ 0 ] is not TextContent textContent )
119+ // We need at least two TextContents in a row to be able to coalesce.
120+ if ( contents [ start ] is not TextContent firstText )
180121 {
122+ start ++ ;
181123 continue ;
182124 }
183125
184- // We found a coalescable item. Look for more contiguous items that are also coalescable with it.
185- int endExclusive = startInclusive + 1 ;
186- for ( ; endExclusive < updates . Count ; endExclusive ++ )
187- {
188- StreamingChatCompletionUpdate next = updates [ endExclusive ] ;
189- if ( next . ChoiceIndex != 0 ||
190- next . Contents . Count != 1 ||
191- next . Contents [ 0 ] is not TextContent ||
192-
193- // changing role or author would be really strange, but check anyway
194- ( update . Role is not null && next . Role is not null && update . Role != next . Role ) ||
195- ( update . AuthorName is not null && next . AuthorName is not null && update . AuthorName != next . AuthorName ) )
196- {
197- break ;
198- }
199- }
200-
201- // If we couldn't find anything to coalesce, there's nothing to do.
202- if ( endExclusive - startInclusive <= 1 )
126+ if ( contents [ start + 1 ] is not TextContent secondText )
203127 {
128+ start += 2 ;
204129 continue ;
205130 }
206131
207- // We found a coalescable run of items. Create a new node to represent the run. We create a new one
208- // rather than reappropriating one of the existing ones so as not to mutate an item already yielded.
209- _ = coalescedText . Clear ( ) . Append ( updates [ startInclusive ] . Text ) ;
210-
211- TextContent coalescedContent = new ( null ) // will patch the text after examining all items in the run
132+ // Append the text from those nodes and continue appending subsequent TextContents until we run out.
133+ // We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
134+ _ = ( coalescedText ??= new ( ) ) . Clear ( ) . Append ( firstText . Text ) . Append ( secondText . Text ) ;
135+ contents [ start + 1 ] = null ! ;
136+ int i = start + 2 ;
137+ for ( ; i < contents . Count && contents [ i ] is TextContent next ; i ++ )
212138 {
213- AdditionalProperties = textContent . AdditionalProperties ? . Clone ( ) ,
214- } ;
139+ _ = coalescedText . Append ( next . Text ) ;
140+ contents [ i ] = null ! ;
141+ }
215142
216- StreamingChatCompletionUpdate coalesced = new ( )
143+ // Store the replacement node.
144+ contents [ start ] = new TextContent ( coalescedText . ToString ( ) )
217145 {
218- AdditionalProperties = update . AdditionalProperties ? . Clone ( ) ,
219- AuthorName = update . AuthorName ,
220- CompletionId = update . CompletionId ,
221- Contents = [ coalescedContent ] ,
222- CreatedAt = update . CreatedAt ,
223- FinishReason = update . FinishReason ,
224- ModelId = update . ModelId ,
225- Role = update . Role ,
226-
227- // Explicitly don't include RawRepresentation. It's not applicable if one update ends up being used
228- // to represent multiple, and it won't be serialized anyway.
146+ // We inherit the properties of the first text node. We don't currently propagate additional
147+ // properties from the subsequent nodes. If we ever need to, we can add that here.
148+ AdditionalProperties = firstText . AdditionalProperties ? . Clone ( ) ,
229149 } ;
230150
231- // Replace the starting node with the coalesced node.
232- updates [ startInclusive ] = coalesced ;
233-
234- // Now iterate through all the rest of the updates in the run, updating the coalesced node with relevant properties,
235- // and nulling out the nodes along the way. We do this rather than removing the entry in order to avoid an O(N^2) operation.
236- // We'll remove all the null entries at the end of the loop, using RemoveAll to do so, which can remove all of
237- // the nulls in a single O(N) pass.
238- for ( int i = startInclusive + 1 ; i < endExclusive ; i ++ )
239- {
240- // Grab the next item.
241- StreamingChatCompletionUpdate next = updates [ i ] ;
242- updates [ i ] = null ! ;
243-
244- var nextContent = ( TextContent ) next . Contents [ 0 ] ;
245- _ = coalescedText . Append ( nextContent . Text ) ;
246-
247- coalesced . AuthorName ??= next . AuthorName ;
248- coalesced . CompletionId ??= next . CompletionId ;
249- coalesced . CreatedAt ??= next . CreatedAt ;
250- coalesced . FinishReason ??= next . FinishReason ;
251- coalesced . ModelId ??= next . ModelId ;
252- coalesced . Role ??= next . Role ;
253- }
254-
255- // Complete the coalescing by patching the text of the coalesced node.
256- coalesced . Text = coalescedText . ToString ( ) ;
257-
258- // Jump to the last update in the run, so that when we loop around and bump ahead,
259- // we're at the next update just after the run.
260- startInclusive = endExclusive - 1 ;
151+ start = i ;
261152 }
262153
263154 // Remove all of the null slots left over from the coalescing process.
264- _ = updates . RemoveAll ( u => u is null ) ;
155+ _ = contents . RemoveAll ( u => u is null ) ;
265156 }
266157}
0 commit comments