diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 3be0a1cc1ee..394fccad1b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -70,7 +70,7 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. @@ -85,7 +85,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. @@ -115,7 +115,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. - private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions) + internal static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions) { // Maps all of the M.E.AI types to the corresponding OpenAI types. // Unrecognized or non-processable content is ignored. @@ -148,7 +148,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op { try { - result = JsonSerializer.Serialize(resultContent.Result, jsonOptions.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -176,7 +176,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op case FunctionCallContent fc: (toolCalls ??= []).Add( ChatToolCall.CreateFunctionToolCall(fc.CallId, fc.Name, new(JsonSerializer.SerializeToUtf8Bytes( - fc.Arguments, jsonOptions.GetTypeInfo(typeof(IDictionary)))))); + fc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); break; default: diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index dccddf3038e..9f42fa88773 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -181,6 +181,18 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI chat messages. + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI response items. + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => + OpenAIResponseChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); + // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. /// Gets whether the properties specify that strict schema handling is desired. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 6aee4bc77e4..c4a1261844c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -17,6 +17,7 @@ #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -466,8 +467,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt } /// Convert a sequence of s to s. - private static IEnumerable ToOpenAIResponseItems( - IEnumerable inputs) + internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs) { foreach (ChatMessage input in inputs) { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs deleted file mode 100644 index ce458473c59..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -// 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.ComponentModel; -using System.Text.Json; -using OpenAI.Assistants; -using OpenAI.Chat; -using OpenAI.Realtime; -using OpenAI.Responses; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OpenAIAIFunctionConversionTests -{ - private static readonly AIFunction _testFunction = AIFunctionFactory.Create( - ([Description("The name parameter")] string name) => name, - "test_function", - "A test function for conversion"); - - [Fact] - public void AsOpenAIChatTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIChatTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.FunctionDescription); - ValidateSchemaParameters(tool.FunctionParameters); - } - - [Fact] - public void AsOpenAIResponseTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIResponseTool(); - - Assert.NotNull(tool); - } - - [Fact] - public void AsOpenAIConversationFunctionTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIConversationFunctionTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.Name); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - [Fact] - public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - /// Helper method to validate function parameters match our schema. - private static void ValidateSchemaParameters(BinaryData parameters) - { - Assert.NotNull(parameters); - - using var jsonDoc = JsonDocument.Parse(parameters); - var root = jsonDoc.RootElement; - - Assert.Equal("object", root.GetProperty("type").GetString()); - Assert.True(root.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("name", out var nameProperty)); - Assert.Equal("string", nameProperty.GetProperty("type").GetString()); - Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs new file mode 100644 index 00000000000..951554eda75 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -0,0 +1,186 @@ +// 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.ComponentModel; +using System.Linq; +using System.Text.Json; +using OpenAI.Assistants; +using OpenAI.Chat; +using OpenAI.Realtime; +using OpenAI.Responses; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIConversionTests +{ + private static readonly AIFunction _testFunction = AIFunctionFactory.Create( + ([Description("The name parameter")] string name) => name, + "test_function", + "A test function for conversion"); + + [Fact] + public void AsOpenAIChatTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIChatTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.FunctionDescription); + ValidateSchemaParameters(tool.FunctionParameters); + } + + [Fact] + public void AsOpenAIResponseTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIResponseTool(); + + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIConversationFunctionTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIConversationFunctionTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.Name); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + [Fact] + public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + /// Helper method to validate function parameters match our schema. + private static void ValidateSchemaParameters(BinaryData parameters) + { + Assert.NotNull(parameters); + + using var jsonDoc = JsonDocument.Parse(parameters); + var root = jsonDoc.RootElement; + + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("name", out var nameProperty)); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); + } + + [Fact] + public void AsOpenAIChatMessages_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIChatMessages()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Equal(5, convertedMessages.Length); + + SystemChatMessage m0 = Assert.IsType(convertedMessages[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + UserChatMessage m1 = Assert.IsType(convertedMessages[1]); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + AssistantChatMessage m2 = Assert.IsType(convertedMessages[2]); + Assert.Single(m2.Content); + Assert.Equal("Hi there!", m2.Content[0].Text); + var tc = Assert.Single(m2.ToolCalls); + Assert.Equal("callid123", tc.Id); + Assert.Equal("SomeFunction", tc.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); + + ToolChatMessage m3 = Assert.IsType(convertedMessages[3]); + Assert.Equal("callid123", m3.ToolCallId); + Assert.Equal("theresult", Assert.Single(m3.Content).Text); + + AssistantChatMessage m4 = Assert.IsType(convertedMessages[4]); + Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); + } + + [Fact] + public void AsOpenAIResponseItems_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIResponseItems()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedItems = messages.AsOpenAIResponseItems().ToArray(); + + Assert.Equal(6, convertedItems.Length); + + MessageResponseItem m0 = Assert.IsAssignableFrom(convertedItems[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + MessageResponseItem m1 = Assert.IsAssignableFrom(convertedItems[1]); + Assert.Equal(OpenAI.Responses.MessageRole.User, m1.Role); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + MessageResponseItem m2 = Assert.IsAssignableFrom(convertedItems[2]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m2.Role); + Assert.Equal("Hi there!", Assert.Single(m2.Content).Text); + + FunctionCallResponseItem m3 = Assert.IsAssignableFrom(convertedItems[3]); + Assert.Equal("callid123", m3.CallId); + Assert.Equal("SomeFunction", m3.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(m3.FunctionArguments.ToMemory().Span))); + + FunctionCallOutputResponseItem m4 = Assert.IsAssignableFrom(convertedItems[4]); + Assert.Equal("callid123", m4.CallId); + Assert.Equal("theresult", m4.FunctionOutput); + + MessageResponseItem m5 = Assert.IsAssignableFrom(convertedItems[5]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role); + Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text); + } +}