Skip to content

Commit f277208

Browse files
stephentoubjeffhandley
authored andcommitted
Expose M.E.AI.OpenAI input message conversions (dotnet#6601)
Internally we have helpers that convert from M.E.AI chat messages to the various OpenAI object models. To ease interop when a developer gets M.E.AI messages from another library and then wants to submit them on their own to OpenAI, this just exposes those helpers publicly.
1 parent c486207 commit f277208

File tree

5 files changed

+205
-84
lines changed

5 files changed

+205
-84
lines changed

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public async Task<ChatResponse> GetResponseAsync(
7070
{
7171
_ = Throw.IfNull(messages);
7272

73-
var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions);
73+
var openAIChatMessages = ToOpenAIChatMessages(messages, options);
7474
var openAIOptions = ToOpenAIOptions(options);
7575

7676
// Make the call to OpenAI.
@@ -85,7 +85,7 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
8585
{
8686
_ = Throw.IfNull(messages);
8787

88-
var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions);
88+
var openAIChatMessages = ToOpenAIChatMessages(messages, options);
8989
var openAIOptions = ToOpenAIOptions(options);
9090

9191
// Make the call to OpenAI.
@@ -115,7 +115,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op
115115
}
116116

117117
/// <summary>Converts an Extensions chat message enumerable to an OpenAI chat message enumerable.</summary>
118-
private static IEnumerable<OpenAI.Chat.ChatMessage> ToOpenAIChatMessages(IEnumerable<ChatMessage> inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions)
118+
internal static IEnumerable<OpenAI.Chat.ChatMessage> ToOpenAIChatMessages(IEnumerable<ChatMessage> inputs, ChatOptions? chatOptions)
119119
{
120120
// Maps all of the M.E.AI types to the corresponding OpenAI types.
121121
// Unrecognized or non-processable content is ignored.
@@ -148,7 +148,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op
148148
{
149149
try
150150
{
151-
result = JsonSerializer.Serialize(resultContent.Result, jsonOptions.GetTypeInfo(typeof(object)));
151+
result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
152152
}
153153
catch (NotSupportedException)
154154
{
@@ -176,7 +176,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op
176176
case FunctionCallContent fc:
177177
(toolCalls ??= []).Add(
178178
ChatToolCall.CreateFunctionToolCall(fc.CallId, fc.Name, new(JsonSerializer.SerializeToUtf8Bytes(
179-
fc.Arguments, jsonOptions.GetTypeInfo(typeof(IDictionary<string, object?>))))));
179+
fc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary<string, object?>))))));
180180
break;
181181

182182
default:

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,18 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) =>
181181
public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) =>
182182
OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function));
183183

184+
/// <summary>Creates a sequence of OpenAI <see cref="OpenAI.Chat.ChatMessage"/> instances from the specified input messages.</summary>
185+
/// <param name="messages">The input messages to convert.</param>
186+
/// <returns>A sequence of OpenAI chat messages.</returns>
187+
public static IEnumerable<OpenAI.Chat.ChatMessage> AsOpenAIChatMessages(this IEnumerable<ChatMessage> messages) =>
188+
OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null);
189+
190+
/// <summary>Creates a sequence of OpenAI <see cref="OpenAI.Responses.ResponseItem"/> instances from the specified input messages.</summary>
191+
/// <param name="messages">The input messages to convert.</param>
192+
/// <returns>A sequence of OpenAI response items.</returns>
193+
public static IEnumerable<OpenAI.Responses.ResponseItem> AsOpenAIResponseItems(this IEnumerable<ChatMessage> messages) =>
194+
OpenAIResponseChatClient.ToOpenAIResponseItems(Throw.IfNull(messages));
195+
184196
// TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict.
185197

186198
/// <summary>Gets whether the properties specify that strict schema handling is desired.</summary>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#pragma warning disable S1067 // Expressions should not be too complex
1818
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
1919
#pragma warning disable S3604 // Member initializer values should not be redundant
20+
#pragma warning disable SA1202 // Elements should be ordered by access
2021
#pragma warning disable SA1204 // Static elements should appear before instance elements
2122

2223
namespace Microsoft.Extensions.AI;
@@ -466,8 +467,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
466467
}
467468

468469
/// <summary>Convert a sequence of <see cref="ChatMessage"/>s to <see cref="ResponseItem"/>s.</summary>
469-
private static IEnumerable<ResponseItem> ToOpenAIResponseItems(
470-
IEnumerable<ChatMessage> inputs)
470+
internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<ChatMessage> inputs)
471471
{
472472
foreach (ChatMessage input in inputs)
473473
{

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

Lines changed: 0 additions & 77 deletions
This file was deleted.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.ComponentModel;
7+
using System.Linq;
8+
using System.Text.Json;
9+
using OpenAI.Assistants;
10+
using OpenAI.Chat;
11+
using OpenAI.Realtime;
12+
using OpenAI.Responses;
13+
using Xunit;
14+
15+
namespace Microsoft.Extensions.AI;
16+
17+
public class OpenAIConversionTests
18+
{
19+
private static readonly AIFunction _testFunction = AIFunctionFactory.Create(
20+
([Description("The name parameter")] string name) => name,
21+
"test_function",
22+
"A test function for conversion");
23+
24+
[Fact]
25+
public void AsOpenAIChatTool_ProducesValidInstance()
26+
{
27+
var tool = _testFunction.AsOpenAIChatTool();
28+
29+
Assert.NotNull(tool);
30+
Assert.Equal("test_function", tool.FunctionName);
31+
Assert.Equal("A test function for conversion", tool.FunctionDescription);
32+
ValidateSchemaParameters(tool.FunctionParameters);
33+
}
34+
35+
[Fact]
36+
public void AsOpenAIResponseTool_ProducesValidInstance()
37+
{
38+
var tool = _testFunction.AsOpenAIResponseTool();
39+
40+
Assert.NotNull(tool);
41+
}
42+
43+
[Fact]
44+
public void AsOpenAIConversationFunctionTool_ProducesValidInstance()
45+
{
46+
var tool = _testFunction.AsOpenAIConversationFunctionTool();
47+
48+
Assert.NotNull(tool);
49+
Assert.Equal("test_function", tool.Name);
50+
Assert.Equal("A test function for conversion", tool.Description);
51+
ValidateSchemaParameters(tool.Parameters);
52+
}
53+
54+
[Fact]
55+
public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance()
56+
{
57+
var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition();
58+
59+
Assert.NotNull(tool);
60+
Assert.Equal("test_function", tool.FunctionName);
61+
Assert.Equal("A test function for conversion", tool.Description);
62+
ValidateSchemaParameters(tool.Parameters);
63+
}
64+
65+
/// <summary>Helper method to validate function parameters match our schema.</summary>
66+
private static void ValidateSchemaParameters(BinaryData parameters)
67+
{
68+
Assert.NotNull(parameters);
69+
70+
using var jsonDoc = JsonDocument.Parse(parameters);
71+
var root = jsonDoc.RootElement;
72+
73+
Assert.Equal("object", root.GetProperty("type").GetString());
74+
Assert.True(root.TryGetProperty("properties", out var properties));
75+
Assert.True(properties.TryGetProperty("name", out var nameProperty));
76+
Assert.Equal("string", nameProperty.GetProperty("type").GetString());
77+
Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString());
78+
}
79+
80+
[Fact]
81+
public void AsOpenAIChatMessages_ProducesExpectedOutput()
82+
{
83+
Assert.Throws<ArgumentNullException>("messages", () => ((IEnumerable<ChatMessage>)null!).AsOpenAIChatMessages());
84+
85+
List<ChatMessage> messages =
86+
[
87+
new(ChatRole.System, "You are a helpful assistant."),
88+
new(ChatRole.User, "Hello"),
89+
new(ChatRole.Assistant,
90+
[
91+
new TextContent("Hi there!"),
92+
new FunctionCallContent("callid123", "SomeFunction", new Dictionary<string, object?>
93+
{
94+
["param1"] = "value1",
95+
["param2"] = 42
96+
}),
97+
]),
98+
new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]),
99+
new(ChatRole.Assistant, "The answer is 42."),
100+
];
101+
102+
var convertedMessages = messages.AsOpenAIChatMessages().ToArray();
103+
104+
Assert.Equal(5, convertedMessages.Length);
105+
106+
SystemChatMessage m0 = Assert.IsType<SystemChatMessage>(convertedMessages[0]);
107+
Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text);
108+
109+
UserChatMessage m1 = Assert.IsType<UserChatMessage>(convertedMessages[1]);
110+
Assert.Equal("Hello", Assert.Single(m1.Content).Text);
111+
112+
AssistantChatMessage m2 = Assert.IsType<AssistantChatMessage>(convertedMessages[2]);
113+
Assert.Single(m2.Content);
114+
Assert.Equal("Hi there!", m2.Content[0].Text);
115+
var tc = Assert.Single(m2.ToolCalls);
116+
Assert.Equal("callid123", tc.Id);
117+
Assert.Equal("SomeFunction", tc.FunctionName);
118+
Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary<string, object?>
119+
{
120+
["param1"] = "value1",
121+
["param2"] = 42
122+
}), JsonSerializer.Deserialize<JsonElement>(tc.FunctionArguments.ToMemory().Span)));
123+
124+
ToolChatMessage m3 = Assert.IsType<ToolChatMessage>(convertedMessages[3]);
125+
Assert.Equal("callid123", m3.ToolCallId);
126+
Assert.Equal("theresult", Assert.Single(m3.Content).Text);
127+
128+
AssistantChatMessage m4 = Assert.IsType<AssistantChatMessage>(convertedMessages[4]);
129+
Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text);
130+
}
131+
132+
[Fact]
133+
public void AsOpenAIResponseItems_ProducesExpectedOutput()
134+
{
135+
Assert.Throws<ArgumentNullException>("messages", () => ((IEnumerable<ChatMessage>)null!).AsOpenAIResponseItems());
136+
137+
List<ChatMessage> messages =
138+
[
139+
new(ChatRole.System, "You are a helpful assistant."),
140+
new(ChatRole.User, "Hello"),
141+
new(ChatRole.Assistant,
142+
[
143+
new TextContent("Hi there!"),
144+
new FunctionCallContent("callid123", "SomeFunction", new Dictionary<string, object?>
145+
{
146+
["param1"] = "value1",
147+
["param2"] = 42
148+
}),
149+
]),
150+
new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]),
151+
new(ChatRole.Assistant, "The answer is 42."),
152+
];
153+
154+
var convertedItems = messages.AsOpenAIResponseItems().ToArray();
155+
156+
Assert.Equal(6, convertedItems.Length);
157+
158+
MessageResponseItem m0 = Assert.IsAssignableFrom<MessageResponseItem>(convertedItems[0]);
159+
Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text);
160+
161+
MessageResponseItem m1 = Assert.IsAssignableFrom<MessageResponseItem>(convertedItems[1]);
162+
Assert.Equal(OpenAI.Responses.MessageRole.User, m1.Role);
163+
Assert.Equal("Hello", Assert.Single(m1.Content).Text);
164+
165+
MessageResponseItem m2 = Assert.IsAssignableFrom<MessageResponseItem>(convertedItems[2]);
166+
Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m2.Role);
167+
Assert.Equal("Hi there!", Assert.Single(m2.Content).Text);
168+
169+
FunctionCallResponseItem m3 = Assert.IsAssignableFrom<FunctionCallResponseItem>(convertedItems[3]);
170+
Assert.Equal("callid123", m3.CallId);
171+
Assert.Equal("SomeFunction", m3.FunctionName);
172+
Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary<string, object?>
173+
{
174+
["param1"] = "value1",
175+
["param2"] = 42
176+
}), JsonSerializer.Deserialize<JsonElement>(m3.FunctionArguments.ToMemory().Span)));
177+
178+
FunctionCallOutputResponseItem m4 = Assert.IsAssignableFrom<FunctionCallOutputResponseItem>(convertedItems[4]);
179+
Assert.Equal("callid123", m4.CallId);
180+
Assert.Equal("theresult", m4.FunctionOutput);
181+
182+
MessageResponseItem m5 = Assert.IsAssignableFrom<MessageResponseItem>(convertedItems[5]);
183+
Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role);
184+
Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text);
185+
}
186+
}

0 commit comments

Comments
 (0)