From 5530539bb362d0393b50b47ac8b5a3d52d3ace6e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 16 Sep 2025 14:24:22 -0400 Subject: [PATCH] Add `IList.Add(ResponseTool)` extension method For someone that knows they're using an OpenAI Responses IChatClient, they can Add a ResponseTool directly into ChatOptions.Tools, rather than needing to go through RawRepresentationFactory. --- .../CHANGELOG.md | 1 + .../CHANGELOG.md | 3 +- ...icrosoftExtensionsAIResponsesExtensions.cs | 42 +++++++++++++++++++ .../OpenAIResponsesChatClient.cs | 11 +++++ .../OpenAIConversionTests.cs | 27 +++++++++++- .../OpenAIResponseClientTests.cs | 19 +++++---- 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index cc911fd64bb..526072374c4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -4,6 +4,7 @@ - Added new `ChatResponseFormat.ForJsonSchema` overloads that export a JSON schema from a .NET type. - Updated `TextReasoningContent` to include `ProtectedData` for representing encrypted/redacted content. +- Fixed `MinLength`/`MaxLength`/`Length` attribute mapping in nullable string properties during schema export. ## 9.9.0 diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index 4d858edeb89..a50aea8dea3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -2,7 +2,8 @@ ## NOT YET RELEASED -- Added M.E.AI to OpenAI conversions for response format types +- Added M.E.AI to OpenAI conversions for response format types. +- Added `ResponseTool` to `AITool` conversions. ## 9.9.0-preview.1.25458.4 diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index ca4bfd3f736..c632f3c45c7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -93,4 +93,46 @@ public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOp previousResponseId: options?.ConversationId, instructions: options?.Instructions); } + + /// Adds the to the list of s. + /// The list of s to which the provided tool should be added. + /// The to add. + /// + /// does not derive from , so it cannot be added directly to a list of s. + /// Instead, this method wraps the provided in an and adds that to the list. + /// The returned by will + /// be able to unwrap the when it processes the list of tools and use the provided as-is. + /// + public static void Add(this IList tools, ResponseTool tool) + { + _ = Throw.IfNull(tools); + + tools.Add(AsAITool(tool)); + } + + /// Creates an to represent a raw . + /// The tool to wrap as an . + /// The wrapped as an . + /// + /// + /// The returned tool is only suitable for use with the returned by + /// (or s that delegate + /// to such an instance). It is likely to be ignored by any other implementation. + /// + /// + /// When a tool has a corresponding -derived type already defined in Microsoft.Extensions.AI, + /// such as , , , or + /// , those types should be preferred instead of this method, as they are more portable, + /// capable of being respected by any implementation. This method does not attempt to + /// map the supplied to any of those types, it simply wraps it as-is: + /// the returned by will + /// be able to unwrap the when it processes the list of tools. + /// + /// + public static AITool AsAITool(this ResponseTool tool) + { + _ = Throw.IfNull(tool); + + return new OpenAIResponsesChatClient.ResponseToolAITool(tool); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 36bf4ca68b4..e2fbfdf3f84 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -414,6 +414,10 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt { switch (tool) { + case ResponseToolAITool rtat: + result.Tools.Add(rtat.Tool); + break; + case AIFunctionDeclaration aiFunction: result.Tools.Add(ToResponseTool(aiFunction, options)); break; @@ -877,4 +881,11 @@ private static void AddAllMcpFilters(IList toolNames, McpToolFilter filt filter.ToolNames.Add(toolName); } } + + /// Provides an wrapper for a . + internal sealed class ResponseToolAITool(ResponseTool tool) : AITool + { + public ResponseTool Tool => tool; + public override string Name => Tool.GetType().Name; + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index b919592da4d..7724ad98360 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -316,7 +316,7 @@ public async Task AsChatResponse_ConvertsOpenAIStreamingChatCompletionUpdates() updates.Add(update); } - ChatResponse response = updates.ToChatResponse(); + var response = updates.ToChatResponse(); Assert.Equal("id", response.ResponseId); Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason); @@ -1176,6 +1176,31 @@ public void AsOpenAIResponse_WithBothModelIds_PrefersChatResponseModelId() Assert.Equal("response-model-id", openAIResponse.Model); } + [Fact] + public void ListAddResponseTool_AddsToolCorrectly() + { + Assert.Throws("tools", () => ((IList)null!).Add(ResponseTool.CreateWebSearchTool())); + Assert.Throws("tool", () => new List().Add((ResponseTool)null!)); + + Assert.Throws("tool", () => ((ResponseTool)null!).AsAITool()); + + ChatOptions options; + + options = new() + { + Tools = new List { ResponseTool.CreateWebSearchTool() }, + }; + Assert.Single(options.Tools); + Assert.NotNull(options.Tools[0]); + + options = new() + { + Tools = [ResponseTool.CreateWebSearchTool().AsAITool()], + }; + Assert.Single(options.Tools); + Assert.NotNull(options.Tools[0]); + } + private static async IAsyncEnumerable CreateAsyncEnumerable(IEnumerable source) { foreach (var item in source) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 9175b6afd57..866e43e172d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -825,8 +825,10 @@ public async Task MultipleOutputItems_NonStreaming() Assert.Equal(36, response.Usage.TotalTokenCount); } - [Fact] - public async Task McpToolCall_ApprovalNotRequired_NonStreaming() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) { const string Input = """ { @@ -1031,13 +1033,16 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming() using HttpClient httpClient = new(handler); using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + AITool mcpTool = rawTool ? + ResponseTool.CreateMcpTool("deepwiki", new("https://mcp.deepwiki.com/mcp"), toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)).AsAITool() : + new HostedMcpServerTool("deepwiki", "https://mcp.deepwiki.com/mcp") + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + }; + ChatOptions chatOptions = new() { - Tools = [new HostedMcpServerTool("deepwiki", "https://mcp.deepwiki.com/mcp") - { - ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, - } - ], + Tools = [mcpTool], }; var response = await client.GetResponseAsync("Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", chatOptions);