Skip to content

Commit 7e172ef

Browse files
stephentoubjeffhandley
authored andcommitted
Update AsOpenAIResponseItems to roundtrip User AIContent ResponseItems (dotnet#6931)
* Update AsOpenAIResponseItems to roundtrip User AIContent ResponseItems For Assistant and Tool messages we're directly roundtripping RawRepresentations that are ResponseItems, but not for User messages. Fix that. * Ensure ordering of AIContent-to-ResponseItem mapping Previously a ResponseItem between two TextContents, for example, would end up being yielded before the text content that came before it. Instead, yield a response item for each group between directly-mapped items. Also fix missing RawRepresentation on McpServerToolApprovalResponseContent.
1 parent b9e9189 commit 7e172ef

File tree

2 files changed

+85
-17
lines changed

2 files changed

+85
-17
lines changed

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

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ internal static IEnumerable<ChatMessage> ToChatMessages(IEnumerable<ResponseItem
202202
break;
203203

204204
case McpToolCallApprovalResponseItem mtcari:
205-
message.Contents.Add(new McpServerToolApprovalResponseContent(mtcari.ApprovalRequestId, mtcari.Approved));
205+
message.Contents.Add(new McpServerToolApprovalResponseContent(mtcari.ApprovalRequestId, mtcari.Approved) { RawRepresentation = mtcari });
206206
break;
207207

208208
case FunctionCallOutputResponseItem functionCallOutputItem:
@@ -663,55 +663,86 @@ internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<Chat
663663

664664
if (input.Role == ChatRole.User)
665665
{
666-
bool handleEmptyMessage = true; // MCP approval responses (and future cases) yield an item rather than adding a part and we don't want to return an empty user message in that case.
667-
List<ResponseContentPart> parts = [];
666+
// Some AIContent items may map to ResponseItems directly. Others map to ResponseContentParts that need to be grouped together.
667+
// In order to preserve ordering, we yield ResponseItems as we find them, grouping ResponseContentParts between those yielded
668+
// items together into their own yielded item.
669+
670+
List<ResponseContentPart>? parts = null;
671+
bool responseItemYielded = false;
672+
668673
foreach (AIContent item in input.Contents)
669674
{
675+
// Items that directly map to a ResponseItem.
676+
ResponseItem? directItem = item switch
677+
{
678+
{ RawRepresentation: ResponseItem rawRep } => rawRep,
679+
McpServerToolApprovalResponseContent mcpResp => ResponseItem.CreateMcpApprovalResponseItem(mcpResp.Id, mcpResp.Approved),
680+
_ => null
681+
};
682+
683+
if (directItem is not null)
684+
{
685+
// Yield any parts already accumulated.
686+
if (parts is not null)
687+
{
688+
yield return ResponseItem.CreateUserMessageItem(parts);
689+
parts = null;
690+
}
691+
692+
// Now yield the directly mapped item.
693+
yield return directItem;
694+
695+
responseItemYielded = true;
696+
continue;
697+
}
698+
699+
// Items that map into ResponseContentParts and are grouped.
670700
switch (item)
671701
{
672702
case AIContent when item.RawRepresentation is ResponseContentPart rawRep:
673-
parts.Add(rawRep);
703+
(parts ??= []).Add(rawRep);
674704
break;
675705

676706
case TextContent textContent:
677-
parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text));
707+
(parts ??= []).Add(ResponseContentPart.CreateInputTextPart(textContent.Text));
678708
break;
679709

680710
case UriContent uriContent when uriContent.HasTopLevelMediaType("image"):
681-
parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri));
711+
(parts ??= []).Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri));
682712
break;
683713

684714
case DataContent dataContent when dataContent.HasTopLevelMediaType("image"):
685-
parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType));
715+
(parts ??= []).Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType));
686716
break;
687717

688718
case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase):
689-
parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"));
719+
(parts ??= []).Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"));
690720
break;
691721

692722
case HostedFileContent fileContent:
693-
parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId));
723+
(parts ??= []).Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId));
694724
break;
695725

696726
case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal):
697-
parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message));
698-
break;
699-
700-
case McpServerToolApprovalResponseContent mcpApprovalResponseContent:
701-
handleEmptyMessage = false;
702-
yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved);
727+
(parts ??= []).Add(ResponseContentPart.CreateRefusalPart(errorContent.Message));
703728
break;
704729
}
705730
}
706731

707-
if (parts.Count == 0 && handleEmptyMessage)
732+
// If we haven't accumulated any parts nor have we yielded any items, manufacture an empty input text part
733+
// to guarantee that every user message results in at least one ResponseItem.
734+
if (parts is null && !responseItemYielded)
708735
{
736+
parts = [];
709737
parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty));
738+
responseItemYielded = true;
710739
}
711740

712-
if (parts.Count > 0)
741+
// Final yield of any accumulated parts.
742+
if (parts is not null)
713743
{
714744
yield return ResponseItem.CreateUserMessageItem(parts);
745+
parts = null;
715746
}
716747

717748
continue;

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,43 @@ public void AsOpenAIResponseItems_ProducesExpectedOutput()
275275
Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text);
276276
}
277277

278+
[Fact]
279+
public void AsOpenAIResponseItems_RoundtripsRawRepresentation()
280+
{
281+
List<ChatMessage> messages =
282+
[
283+
new(ChatRole.User,
284+
[
285+
new TextContent("Hello, "),
286+
new AIContent { RawRepresentation = ResponseItem.CreateWebSearchCallItem() },
287+
new AIContent { RawRepresentation = ResponseItem.CreateReferenceItem("123") },
288+
new TextContent("World"),
289+
new TextContent("!"),
290+
]),
291+
new(ChatRole.Assistant,
292+
[
293+
new TextContent("Hi!"),
294+
new AIContent { RawRepresentation = ResponseItem.CreateReasoningItem("text") },
295+
]),
296+
new(ChatRole.User,
297+
[
298+
new AIContent { RawRepresentation = ResponseItem.CreateSystemMessageItem("test") },
299+
]),
300+
];
301+
302+
var items = messages.AsOpenAIResponseItems().ToArray();
303+
304+
Assert.Equal(7, items.Length);
305+
Assert.Equal("Hello, ", ((MessageResponseItem)items[0]).Content[0].Text);
306+
Assert.Same(messages[0].Contents[1].RawRepresentation, items[1]);
307+
Assert.Same(messages[0].Contents[2].RawRepresentation, items[2]);
308+
Assert.Equal("World", ((MessageResponseItem)items[3]).Content[0].Text);
309+
Assert.Equal("!", ((MessageResponseItem)items[3]).Content[1].Text);
310+
Assert.Equal("Hi!", ((MessageResponseItem)items[4]).Content[0].Text);
311+
Assert.Same(messages[1].Contents[1].RawRepresentation, items[5]);
312+
Assert.Same(messages[2].Contents[0].RawRepresentation, items[6]);
313+
}
314+
278315
[Fact]
279316
public void AsChatResponse_ConvertsOpenAIChatCompletion()
280317
{

0 commit comments

Comments
 (0)