diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs
new file mode 100644
index 00000000000..05ac80dd682
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs
@@ -0,0 +1,212 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+#if NET
+using System.Runtime.InteropServices;
+#endif
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
+
+#pragma warning disable S109 // Magic numbers should not be used
+#pragma warning disable S127 // "for" loop stop conditions should be invariant
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Provides extension methods for working with instances.
+///
+public static class StreamingChatCompletionUpdateExtensions
+{
+ /// Combines instances into a single .
+ /// The updates to be combined.
+ ///
+ /// to attempt to coalesce contiguous items, where applicable,
+ /// into a single , in order to reduce the number of individual content items that are included in
+ /// the manufactured instances. When , the original content items are used.
+ /// The default is .
+ ///
+ /// The combined .
+ public static ChatCompletion ToChatCompletion(
+ this IEnumerable updates, bool coalesceContent = true)
+ {
+ _ = Throw.IfNull(updates);
+
+ ChatCompletion completion = new([]);
+ Dictionary messages = [];
+
+ foreach (var update in updates)
+ {
+ ProcessUpdate(update, messages, completion);
+ }
+
+ AddMessagesToCompletion(messages, completion, coalesceContent);
+
+ return completion;
+ }
+
+ /// Combines instances into a single .
+ /// The updates to be combined.
+ ///
+ /// to attempt to coalesce contiguous items, where applicable,
+ /// into a single , in order to reduce the number of individual content items that are included in
+ /// the manufactured instances. When , the original content items are used.
+ /// The default is .
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ /// The combined .
+ public static Task ToChatCompletionAsync(
+ this IAsyncEnumerable updates, bool coalesceContent = true, CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(updates);
+
+ return ToChatCompletionAsync(updates, coalesceContent, cancellationToken);
+
+ static async Task ToChatCompletionAsync(
+ IAsyncEnumerable updates, bool coalesceContent, CancellationToken cancellationToken)
+ {
+ ChatCompletion completion = new([]);
+ Dictionary messages = [];
+
+ await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ ProcessUpdate(update, messages, completion);
+ }
+
+ AddMessagesToCompletion(messages, completion, coalesceContent);
+
+ return completion;
+ }
+ }
+
+ /// Processes the , incorporating its contents into and .
+ /// The update to process.
+ /// The dictionary mapping to the being built for that choice.
+ /// The object whose properties should be updated based on .
+ private static void ProcessUpdate(StreamingChatCompletionUpdate update, Dictionary messages, ChatCompletion completion)
+ {
+ completion.CompletionId ??= update.CompletionId;
+ completion.CreatedAt ??= update.CreatedAt;
+ completion.FinishReason ??= update.FinishReason;
+ completion.ModelId ??= update.ModelId;
+
+#if NET
+ ChatMessage message = CollectionsMarshal.GetValueRefOrAddDefault(messages, update.ChoiceIndex, out _) ??=
+ new(default, new List());
+#else
+ if (!messages.TryGetValue(update.ChoiceIndex, out ChatMessage? message))
+ {
+ messages[update.ChoiceIndex] = message = new(default, new List());
+ }
+#endif
+
+ ((List)message.Contents).AddRange(update.Contents);
+
+ message.AuthorName ??= update.AuthorName;
+ if (update.Role is ChatRole role && message.Role == default)
+ {
+ message.Role = role;
+ }
+
+ if (update.AdditionalProperties is not null)
+ {
+ if (message.AdditionalProperties is null)
+ {
+ message.AdditionalProperties = new(update.AdditionalProperties);
+ }
+ else
+ {
+ foreach (var entry in update.AdditionalProperties)
+ {
+ // Use first-wins behavior to match the behavior of the other properties.
+ _ = message.AdditionalProperties.TryAdd(entry.Key, entry.Value);
+ }
+ }
+ }
+ }
+
+ /// Finalizes the object by transferring the into it.
+ /// The messages to process further and transfer into .
+ /// The result being built.
+ /// The corresponding option value provided to or .
+ private static void AddMessagesToCompletion(Dictionary messages, ChatCompletion completion, bool coalesceContent)
+ {
+ foreach (var entry in messages)
+ {
+ if (entry.Value.Role == default)
+ {
+ entry.Value.Role = ChatRole.Assistant;
+ }
+
+ if (coalesceContent)
+ {
+ CoalesceTextContent((List)entry.Value.Contents);
+ }
+
+ completion.Choices.Add(entry.Value);
+
+ if (completion.Usage is null)
+ {
+ foreach (var content in entry.Value.Contents)
+ {
+ if (content is UsageContent c)
+ {
+ completion.Usage = c.Details;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /// Coalesces sequential content elements.
+ private static void CoalesceTextContent(List contents)
+ {
+ StringBuilder? coalescedText = null;
+
+ // Iterate through all of the items in the list looking for contiguous items that can be coalesced.
+ int start = 0;
+ while (start < contents.Count - 1)
+ {
+ // We need at least two TextContents in a row to be able to coalesce.
+ if (contents[start] is not TextContent firstText)
+ {
+ start++;
+ continue;
+ }
+
+ if (contents[start + 1] is not TextContent secondText)
+ {
+ start += 2;
+ continue;
+ }
+
+ // Append the text from those nodes and continue appending subsequent TextContents until we run out.
+ // We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
+ coalescedText ??= new();
+ _ = coalescedText.Clear().Append(firstText.Text).Append(secondText.Text);
+ contents[start + 1] = null!;
+ int i = start + 2;
+ for (; i < contents.Count && contents[i] is TextContent next; i++)
+ {
+ _ = coalescedText.Append(next.Text);
+ contents[i] = null!;
+ }
+
+ // Store the replacement node.
+ contents[start] = new TextContent(coalescedText.ToString())
+ {
+ // We inherit the properties of the first text node. We don't currently propagate additional
+ // properties from the subsequent nodes. If we ever need to, we can add that here.
+ AdditionalProperties = firstText.AdditionalProperties?.Clone(),
+ };
+
+ start = i;
+ }
+
+ // Remove all of the null slots left over from the coalescing process.
+ _ = contents.RemoveAll(u => u is null);
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs
new file mode 100644
index 00000000000..bb0f08325d5
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs
@@ -0,0 +1,200 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+
+#pragma warning disable SA1204 // Static elements should appear before instance elements
+
+namespace Microsoft.Extensions.AI;
+
+public class StreamingChatCompletionUpdateExtensionsTests
+{
+ [Fact]
+ public void InvalidArgs_Throws()
+ {
+ Assert.Throws("updates", () => ((List)null!).ToChatCompletion());
+ }
+
+ public static IEnumerable