diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs index 43b3e321df9..9e36f548c00 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -53,6 +53,7 @@ public ChatMessage Clone() => AdditionalProperties = AdditionalProperties, _authorName = _authorName, _contents = _contents, + CreatedAt = CreatedAt, RawRepresentation = RawRepresentation, Role = Role, MessageId = MessageId, @@ -65,6 +66,9 @@ public string? AuthorName set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; } + /// Gets or sets a timestamp for the chat message. + public DateTimeOffset? CreatedAt { get; set; } + /// Gets or sets the role of the author of the message. public ChatRole Role { get; set; } = ChatRole.User; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs index a342ef1e69e..0889fed17d6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs @@ -130,19 +130,19 @@ public ChatResponseUpdate[] ToChatResponseUpdates() ChatMessage message = _messages![i]; updates[i] = new ChatResponseUpdate { - ConversationId = ConversationId, - AdditionalProperties = message.AdditionalProperties, AuthorName = message.AuthorName, Contents = message.Contents, + MessageId = message.MessageId, RawRepresentation = message.RawRepresentation, Role = message.Role, - ResponseId = ResponseId, - MessageId = message.MessageId, - CreatedAt = CreatedAt, + ConversationId = ConversationId, FinishReason = FinishReason, - ModelId = ModelId + ModelId = ModelId, + ResponseId = ResponseId, + + CreatedAt = message.CreatedAt ?? CreatedAt, }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 01ce878e79c..6691c665c54 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -84,9 +84,10 @@ public static void AddMessages(this IList list, ChatResponseUpdate var contentsList = filter is null ? update.Contents : update.Contents.Where(filter).ToList(); if (contentsList.Count > 0) { - list.Add(new ChatMessage(update.Role ?? ChatRole.Assistant, contentsList) + list.Add(new(update.Role ?? ChatRole.Assistant, contentsList) { AuthorName = update.AuthorName, + CreatedAt = update.CreatedAt, RawRepresentation = update.RawRepresentation, AdditionalProperties = update.AdditionalProperties, }); @@ -268,7 +269,7 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon if (isNewMessage) { - message = new ChatMessage(ChatRole.Assistant, []); + message = new(ChatRole.Assistant, []); response.Messages.Add(message); } else @@ -280,11 +281,17 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon // Incorporate those into the latest message; in cases where the message // stores a single value, prefer the latest update's value over anything // stored in the message. + if (update.AuthorName is not null) { message.AuthorName = update.AuthorName; } + if (update.CreatedAt is not null) + { + message.CreatedAt = update.CreatedAt; + } + if (update.Role is ChatRole role) { message.Role = role; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 4d190fccda8..9562de0d93f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -883,6 +883,10 @@ "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.ChatMessage.Contents { get; set; }", "Stage": "Stable" }, + { + "Member": "System.DateTimeOffset? Microsoft.Extensions.AI.ChatMessage.CreatedAt { get; set; }", + "Stage": "Stable" + }, { "Member": "string? Microsoft.Extensions.AI.ChatMessage.MessageId { get; set; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index bb23e1de489..0fd8f4506db 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -95,6 +95,7 @@ public async Task GetResponseAsync( // Create the return message. ChatMessage message = new(ToChatRole(response.Role), response.Content) { + CreatedAt = response.Created, MessageId = response.Id, // There is no per-message ID, but there's only one message per response, so use the response ID RawRepresentation = response, }; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 9fc2f3cbeef..a70fcec2a8f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -438,6 +438,7 @@ internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompl // Create the return message. ChatMessage returnMessage = new() { + CreatedAt = openAICompletion.CreatedAt, MessageId = openAICompletion.Id, // There's no per-message ID, so we use the same value as the response ID RawRepresentation = openAICompletion, Role = FromOpenAIChatRole(openAICompletion.Role), diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 60d0e10a159..dfe0c50d374 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -162,6 +162,11 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R } } + foreach (var message in response.Messages) + { + message.CreatedAt = openAIResponse.CreatedAt; + } + return response; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs index c449f064255..7fd9591a6ae 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -18,6 +18,7 @@ public void Constructor_Parameterless_PropsDefaulted() ChatMessage message = new(); Assert.Null(message.AuthorName); Assert.Empty(message.Contents); + Assert.Null(message.CreatedAt); Assert.Equal(ChatRole.User, message.Role); Assert.Empty(message.Text); Assert.NotNull(message.Contents); @@ -50,6 +51,7 @@ public void Constructor_RoleString_PropsRoundtrip(string? text) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); Assert.Equal(text ?? string.Empty, message.ToString()); @@ -113,6 +115,7 @@ public void Constructor_RoleList_PropsRoundtrip(int messageCount) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); } @@ -230,6 +233,20 @@ public void AdditionalProperties_Roundtrips() Assert.Same(props, message.AdditionalProperties); } + [Fact] + public void CreatedAt_Roundtrips() + { + ChatMessage message = new(); + Assert.Null(message.CreatedAt); + + DateTimeOffset now = DateTimeOffset.Now; + message.CreatedAt = now; + Assert.Equal(now, message.CreatedAt); + + message.CreatedAt = null; + Assert.Null(message.CreatedAt); + } + [Fact] public void ItCanBeSerializeAndDeserialized() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs index 802b414437d..de5809d3d97 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs @@ -125,7 +125,7 @@ public void ToString_OutputsText() } [Fact] - public void ToChatResponseUpdates() + public void ToChatResponseUpdates_SingleMessage() { ChatResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage" }) { @@ -153,4 +153,55 @@ public void ToChatResponseUpdates() Assert.Equal("value1", update1.AdditionalProperties?["key1"]); Assert.Equal(42, update1.AdditionalProperties?["key2"]); } + + [Fact] + public void ToChatResponseUpdates_MultipleMessages() + { + ChatResponse response = new( + [ + new ChatMessage(new ChatRole("customRole"), "Text") + { + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + MessageId = "someMessage" + }, + new ChatMessage(new ChatRole("secondRole"), "Another message") + { + CreatedAt = new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), + MessageId = "anotherMessage" + } + ]) + { + ResponseId = "12345", + ModelId = "someModel", + FinishReason = ChatFinishReason.ContentFilter, + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + }; + + ChatResponseUpdate[] updates = response.ToChatResponseUpdates(); + Assert.NotNull(updates); + Assert.Equal(3, updates.Length); + + ChatResponseUpdate update0 = updates[0]; + Assert.Equal("12345", update0.ResponseId); + Assert.Equal("someMessage", update0.MessageId); + Assert.Equal("someModel", update0.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason); + Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt); + Assert.Equal("customRole", update0.Role?.Value); + Assert.Equal("Text", update0.Text); + + ChatResponseUpdate update1 = updates[1]; + Assert.Equal("12345", update1.ResponseId); + Assert.Equal("anotherMessage", update1.MessageId); + Assert.Equal("someModel", update1.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update1.FinishReason); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), update1.CreatedAt); + Assert.Equal("secondRole", update1.Role?.Value); + Assert.Equal("Another message", update1.Text); + + ChatResponseUpdate update2 = updates[2]; + Assert.Equal("value1", update2.AdditionalProperties?["key1"]); + Assert.Equal(42, update2.AdditionalProperties?["key2"]); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs index 8b13d640ae1..2eb8db9b477 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -65,6 +65,65 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) Assert.Equal("Hello, world!", response.Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message - ID "msg1" + new(null, "Hi! ") { CreatedAt = new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(ChatRole.Assistant, "Hello") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(null, " from") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero) }, // Later CreatedAt should win + new(null, " AI") { MessageId = "msg1", AuthorName = "AI Assistant" }, // Later AuthorName should win + + // Second message - ID "msg2" + new(ChatRole.User, "How") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), AuthorName = "User" }, + new(null, " are") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero) }, + new(null, " you?") { MessageId = "msg2", AuthorName = "Human User" }, // Later AuthorName should win + + // Third message - ID "msg3" + new(ChatRole.Assistant, "I'm doing well,") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero) }, + new(null, " thank you!") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should win + + // Updates without MessageId should continue the last message (msg3) + new(null, " How can I help?"), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.NotNull(response); + Assert.Equal(3, response.Messages.Count); + + // Verify first message + ChatMessage message1 = response.Messages[0]; + Assert.Equal("msg1", message1.MessageId); + Assert.Equal(ChatRole.Assistant, message1.Role); + Assert.Equal("AI Assistant", message1.AuthorName); // Last value should win + Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero), message1.CreatedAt); // Last value should win + Assert.Equal("Hi! Hello from AI", message1.Text); + + // Verify second message + ChatMessage message2 = response.Messages[1]; + Assert.Equal("msg2", message2.MessageId); + Assert.Equal(ChatRole.User, message2.Role); + Assert.Equal("Human User", message2.AuthorName); // Last value should win + Assert.Equal(new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero), message2.CreatedAt); // Last value should win + Assert.Equal("How are you?", message2.Text); + + // Verify third message + ChatMessage message3 = response.Messages[2]; + Assert.Equal("msg3", message3.MessageId); + Assert.Equal(ChatRole.Assistant, message3.Role); + Assert.Null(message3.AuthorName); // No AuthorName set in later updates + Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero), message3.CreatedAt); // Last value should win + Assert.Equal("I'm doing well, thank you! How can I help?", message3.Text); + } + public static IEnumerable ToChatResponse_Coalescing_VariousSequenceAndGapLengths_MemberData() { foreach (bool useAsync in new[] { false, true })