diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 0d119a742e2..ea80ae8e794 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -21,6 +21,17 @@ public static class MicrosoftExtensionsAIResponsesExtensions public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration function) => OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(function)); + /// Creates an OpenAI from an . + /// The tool to convert. + /// An OpenAI representing or if there is no mapping. + /// is . + /// + /// This method is only able to create s for types + /// it's aware of, namely all of those available from the Microsoft.Extensions.AI.Abstractions library. + /// + public static ResponseTool? AsOpenAIResponseTool(this AITool tool) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool)); + /// /// Creates an OpenAI from a . /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 039d04c1f8e..c1617dc0d08 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -431,6 +431,97 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } + internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null) + { + switch (tool) + { + case ResponseToolAITool rtat: + return rtat.Tool; + + case AIFunctionDeclaration aiFunction: + return ToResponseTool(aiFunction, options); + + case HostedWebSearchTool webSearchTool: + WebSearchToolLocation? location = null; + if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) + { + location = objLocation as WebSearchToolLocation; + } + + WebSearchToolContextSize? size = null; + if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && + objSize is WebSearchToolContextSize) + { + size = (WebSearchToolContextSize)objSize; + } + + return ResponseTool.CreateWebSearchTool(location, size); + + case HostedFileSearchTool fileSearchTool: + return ResponseTool.CreateFileSearchTool( + fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], + fileSearchTool.MaximumResultCount); + + case HostedCodeInterpreterTool codeTool: + return ResponseTool.CreateCodeInterpreterTool( + new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? + CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(ids) : + new())); + + case HostedMcpServerTool mcpTool: + McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + url, + mcpTool.AuthorizationToken, + mcpTool.ServerDescription) : + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + new McpToolConnectorId(mcpTool.ServerAddress), + mcpTool.AuthorizationToken, + mcpTool.ServerDescription); + + if (mcpTool.AllowedTools is not null) + { + responsesMcpTool.AllowedTools = new(); + AddAllMcpFilters(mcpTool.AllowedTools, responsesMcpTool.AllowedTools); + } + + switch (mcpTool.ApprovalMode) + { + case HostedMcpServerToolAlwaysRequireApprovalMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval); + break; + + case HostedMcpServerToolNeverRequireApprovalMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval); + break; + + case HostedMcpServerToolRequireSpecificApprovalMode specificMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(new CustomMcpToolCallApprovalPolicy()); + + if (specificMode.AlwaysRequireApprovalToolNames is { Count: > 0 } alwaysRequireToolNames) + { + responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval = new(); + AddAllMcpFilters(alwaysRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval); + } + + if (specificMode.NeverRequireApprovalToolNames is { Count: > 0 } neverRequireToolNames) + { + responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval = new(); + AddAllMcpFilters(neverRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval); + } + + break; + } + + return responsesMcpTool; + + default: + return null; + } + } + internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = @@ -492,96 +583,9 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt { foreach (AITool tool in tools) { - switch (tool) + if (ToResponseTool(tool, options) is { } responseTool) { - case ResponseToolAITool rtat: - result.Tools.Add(rtat.Tool); - break; - - case AIFunctionDeclaration aiFunction: - result.Tools.Add(ToResponseTool(aiFunction, options)); - break; - - case HostedWebSearchTool webSearchTool: - WebSearchToolLocation? location = null; - if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) - { - location = objLocation as WebSearchToolLocation; - } - - WebSearchToolContextSize? size = null; - if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && - objSize is WebSearchToolContextSize) - { - size = (WebSearchToolContextSize)objSize; - } - - result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size)); - break; - - case HostedFileSearchTool fileSearchTool: - result.Tools.Add(ResponseTool.CreateFileSearchTool( - fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], - fileSearchTool.MaximumResultCount)); - break; - - case HostedCodeInterpreterTool codeTool: - result.Tools.Add( - ResponseTool.CreateCodeInterpreterTool( - new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? - CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(ids) : - new()))); - break; - - case HostedMcpServerTool mcpTool: - McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? - ResponseTool.CreateMcpTool( - mcpTool.ServerName, - url, - mcpTool.AuthorizationToken, - mcpTool.ServerDescription) : - ResponseTool.CreateMcpTool( - mcpTool.ServerName, - new McpToolConnectorId(mcpTool.ServerAddress), - mcpTool.AuthorizationToken, - mcpTool.ServerDescription); - - if (mcpTool.AllowedTools is not null) - { - responsesMcpTool.AllowedTools = new(); - AddAllMcpFilters(mcpTool.AllowedTools, responsesMcpTool.AllowedTools); - } - - switch (mcpTool.ApprovalMode) - { - case HostedMcpServerToolAlwaysRequireApprovalMode: - responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval); - break; - - case HostedMcpServerToolNeverRequireApprovalMode: - responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval); - break; - - case HostedMcpServerToolRequireSpecificApprovalMode specificMode: - responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(new CustomMcpToolCallApprovalPolicy()); - - if (specificMode.AlwaysRequireApprovalToolNames is { Count: > 0 } alwaysRequireToolNames) - { - responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval = new(); - AddAllMcpFilters(alwaysRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval); - } - - if (specificMode.NeverRequireApprovalToolNames is { Count: > 0 } neverRequireToolNames) - { - responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval = new(); - AddAllMcpFilters(neverRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval); - } - - break; - } - - result.Tools.Add(responsesMcpTool); - break; + result.Tools.Add(responseTool); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 844fb5618ed..7fe1ceb8b57 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -112,6 +112,283 @@ public void AsOpenAIResponseTool_ProducesValidInstance() Assert.NotNull(tool); } + [Fact] + public void AsOpenAIResponseTool_WithAIFunctionTool_ProducesValidFunctionTool() + { + var tool = MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(tool: _testFunction); + + Assert.NotNull(tool); + var functionTool = Assert.IsType(tool); + Assert.Equal("test_function", functionTool.FunctionName); + Assert.Equal("A test function for conversion", functionTool.FunctionDescription); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedWebSearchTool_ProducesValidWebSearchTool() + { + var webSearchTool = new HostedWebSearchTool(); + + var result = webSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedWebSearchToolWithAdditionalProperties_ProducesValidWebSearchTool() + { + var location = WebSearchToolLocation.CreateApproximateLocation("US", "Region", "City", "UTC"); + var webSearchTool = new HostedWebSearchToolWithProperties(new Dictionary + { + [nameof(WebSearchToolLocation)] = location, + [nameof(WebSearchToolContextSize)] = WebSearchToolContextSize.High + }); + + var result = webSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + + var tool = Assert.IsType(result); + Assert.Equal(WebSearchToolContextSize.High, tool.SearchContextSize); + Assert.NotNull(tool); + + var approximateLocation = Assert.IsType(tool.UserLocation); + Assert.Equal(location.Country, approximateLocation.Country); + Assert.Equal(location.Region, approximateLocation.Region); + Assert.Equal(location.City, approximateLocation.City); + Assert.Equal(location.Timezone, approximateLocation.Timezone); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchTool_ProducesValidFileSearchTool() + { + var fileSearchTool = new HostedFileSearchTool { MaximumResultCount = 10 }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Empty(tool.VectorStoreIds); + Assert.Equal(fileSearchTool.MaximumResultCount, tool.MaxResultCount); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchToolWithVectorStores_ProducesValidFileSearchTool() + { + var vectorStoreContent = new HostedVectorStoreContent("vector-store-123"); + var fileSearchTool = new HostedFileSearchTool + { + Inputs = [vectorStoreContent] + }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Single(tool.VectorStoreIds); + Assert.Equal(vectorStoreContent.VectorStoreId, tool.VectorStoreIds[0]); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchToolWithMaxResults_ProducesValidFileSearchTool() + { + var fileSearchTool = new HostedFileSearchTool + { + MaximumResultCount = 10 + }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(10, tool.MaxResultCount); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedCodeInterpreterTool_ProducesValidCodeInterpreterTool() + { + var codeTool = new HostedCodeInterpreterTool(); + + var result = codeTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.Container); + Assert.NotNull(tool.Container.ContainerConfiguration); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedCodeInterpreterToolWithFiles_ProducesValidCodeInterpreterTool() + { + var fileContent = new HostedFileContent("file-123"); + var codeTool = new HostedCodeInterpreterTool + { + Inputs = [fileContent] + }; + + var result = codeTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + var autoContainerConfig = Assert.IsType(tool.Container.ContainerConfiguration); + Assert.Single(autoContainerConfig.FileIds); + Assert.Equal(fileContent.FileId, autoContainerConfig.FileIds[0]); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerTool_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000"); + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(new Uri("http://localhost:8000"), tool.ServerUri); + Assert.Equal("test-server", tool.ServerLabel); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithDescription_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ServerDescription = "A test MCP server" + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("A test MCP server", tool.ServerDescription); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAuthToken_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + AuthorizationToken = "test-token" + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("test-token", tool.AuthorizationToken); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithUri_ProducesValidMcpTool() + { + var expectedUri = new Uri("http://localhost:8000"); + var mcpTool = new HostedMcpServerTool("test-server", expectedUri); + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(expectedUri, tool.ServerUri); + Assert.Equal("test-server", tool.ServerLabel); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAllowedTools_ProducesValidMcpTool() + { + var allowedTools = new List { "tool1", "tool2", "tool3" }; + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + AllowedTools = allowedTools + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.AllowedTools); + Assert.Equal(3, tool.AllowedTools.ToolNames.Count); + Assert.Contains("tool1", tool.AllowedTools.ToolNames); + Assert.Contains("tool2", tool.AllowedTools.ToolNames); + Assert.Contains("tool3", tool.AllowedTools.ToolNames); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAlwaysRequireApprovalMode_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.GlobalPolicy); + Assert.Equal(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval, tool.ToolCallApprovalPolicy.GlobalPolicy); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithNeverRequireApprovalMode_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.GlobalPolicy); + Assert.Equal(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval, tool.ToolCallApprovalPolicy.GlobalPolicy); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithRequireSpecificApprovalMode_ProducesValidMcpTool() + { + var alwaysRequireTools = new List { "tool1", "tool2" }; + var neverRequireTools = new List { "tool3" }; + var approvalMode = HostedMcpServerToolApprovalMode.RequireSpecific(alwaysRequireTools, neverRequireTools); + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = approvalMode + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval); + Assert.Equal(2, tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames.Count); + Assert.Single(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval.ToolNames); + Assert.Contains("tool1", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames); + Assert.Contains("tool2", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames); + Assert.Contains("tool3", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval.ToolNames); + } + + [Fact] + public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull() + { + var unknownTool = new UnknownAITool(); + + var result = unknownTool.AsOpenAIResponseTool(); + + Assert.Null(result); + } + + [Fact] + public void AsOpenAIResponseTool_WithNullTool_ThrowsArgumentNullException() + { + Assert.Throws("tool", () => ((AITool)null!).AsOpenAIResponseTool()); + } + [Fact] public void AsOpenAIConversationFunctionTool_ProducesValidInstance() { @@ -1256,4 +1533,23 @@ private static async IAsyncEnumerable CreateAsyncEnumerable(IEnumerable } private static string RemoveWhitespace(string input) => Regex.Replace(input, @"\s+", ""); + + /// Helper class for testing unknown tool types. + private sealed class UnknownAITool : AITool + { + public override string Name => "unknown_tool"; + } + + /// Helper class for testing WebSearchTool with additional properties. + private sealed class HostedWebSearchToolWithProperties : HostedWebSearchTool + { + private readonly Dictionary _additionalProperties; + + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties; + + public HostedWebSearchToolWithProperties(Dictionary additionalProperties) + { + _additionalProperties = additionalProperties; + } + } }