diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs index 38ebcf0ffd9..727e29d766b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -11,7 +13,8 @@ namespace Microsoft.Extensions.AI; /// /// Provides an optional base class for an that passes through calls to another instance. /// -internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand +[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] +public class DelegatingAIFunctionDeclaration : AIFunctionDeclaration { /// /// Initializes a new instance of the class as a wrapper around . diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs new file mode 100644 index 00000000000..c49b723e5bf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an that signals to supporting AI services that deferred +/// loading should be used when tool search is enabled. Only the function's name and description are sent initially; +/// the full JSON schema is loaded on demand by the service when the model selects this tool. +/// +/// +/// This class is a marker/decorator that signals to a supporting provider that the function should be +/// sent with deferred loading (only name and description upfront). Use to create +/// a complete tool list including a and wrapped functions. +/// +[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class SearchableAIFunctionDeclaration : DelegatingAIFunctionDeclaration +{ + /// + /// Initializes a new instance of the class. + /// + /// The represented by this instance. + /// An optional namespace for grouping related tools in the tool search index. + /// is . + public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespaceName = null) + : base(innerFunction) + { + Namespace = namespaceName; + } + + /// Gets the optional namespace this function belongs to, for grouping related tools in the tool search index. + public string? Namespace { get; } + + /// + /// Creates a complete tool list with a and the given functions wrapped as . + /// + /// The functions to include as searchable tools. + /// An optional namespace for grouping related tools. + /// Any additional properties to pass to the . + /// A list of instances ready for use in . + /// is . + public static IList CreateToolSet( + IEnumerable functions, + string? namespaceName = null, + IReadOnlyDictionary? toolSearchProperties = null) + { + _ = Throw.IfNull(functions); + + var tools = new List { new HostedToolSearchTool(toolSearchProperties) }; + foreach (var fn in functions) + { + tools.Add(new SearchableAIFunctionDeclaration(fn, namespaceName)); + } + + return tools; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index d96698776db..c3433dcfff6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -143,4 +145,20 @@ private static string ValidateUrl(Uri serverAddress) /// /// public IDictionary? Headers { get; set; } + + /// + /// Gets or sets a value indicating whether tools from this MCP server should use deferred loading when tool search is enabled. + /// + /// + /// + /// When set to and a is present in , + /// the provider will signal to the AI service that tools from this MCP server should be deferred — only their names and + /// descriptions are sent initially, and full schemas are loaded on demand when the model selects them. + /// + /// + /// The default value is . + /// + /// + [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] + public bool DeferLoadingTools { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs new file mode 100644 index 00000000000..4fd90e06449 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to search for and selectively load tool definitions on demand. +/// +/// This tool does not itself implement tool search. It is a marker that can be used to inform a service +/// that tool search should be enabled, reducing token usage by deferring full tool schema loading until the model requests it. +/// +[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] +public class HostedToolSearchTool : AITool +{ + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + + /// Initializes a new instance of the class. + public HostedToolSearchTool() + { + } + + /// Initializes a new instance of the class. + /// Any additional properties associated with the tool. + public HostedToolSearchTool(IReadOnlyDictionary? additionalProperties) + { + _additionalProperties = additionalProperties; + } + + /// + public override string Name => "tool_search"; + + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index ccc4fbcb230..c9464778dc6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -18,6 +18,7 @@ using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; +using OpenAI; using OpenAI.Responses; #pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored @@ -702,7 +703,22 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo return rtat.Tool; case AIFunctionDeclaration aiFunction: - return ToResponseTool(aiFunction, options); + var functionTool = ToResponseTool(aiFunction, options); + if (tool.GetService() is { } searchable) + { + functionTool.Patch.Set("$.defer_loading"u8, "true"u8); + if (searchable.Namespace is { } ns) + { + functionTool.Patch.Set("$.namespace"u8, JsonSerializer.SerializeToUtf8Bytes(ns, OpenAIJsonContext.Default.String).AsSpan()); + } + } + + return functionTool; + + case HostedToolSearchTool: + // Workaround: The OpenAI .NET SDK doesn't yet expose a ToolSearchTool type. + // See https://github.com/openai/openai-dotnet/issues/1053 + return ModelReaderWriter.Read(BinaryData.FromString("""{"type": "tool_search"}"""), ModelReaderWriterOptions.Json, OpenAIContext.Default)!; case HostedWebSearchTool webSearchTool: return new WebSearchTool @@ -814,6 +830,11 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo break; } + if (mcpTool.DeferLoadingTools) + { + responsesMcpTool.Patch.Set("$.defer_loading"u8, "true"u8); + } + return responsesMcpTool; default: diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index 94cc1a1f04a..c780f357a04 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -58,6 +58,7 @@ internal static class Experiments internal const string AIResponseContinuations = AIExperiments; internal const string AICodeInterpreter = AIExperiments; internal const string AIWebSearch = AIExperiments; + internal const string AIToolSearch = AIExperiments; internal const string AIRealTime = AIExperiments; internal const string AIFiles = AIExperiments; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs new file mode 100644 index 00000000000..a708ceee119 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs @@ -0,0 +1,108 @@ +// 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 Xunit; + +namespace Microsoft.Extensions.AI.Functions; + +public class SearchableAIFunctionDeclarationTests +{ + [Fact] + public void Constructor_NullFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new SearchableAIFunctionDeclaration(null!)); + } + + [Fact] + public void Constructor_DelegatesToInnerFunction_Properties() + { + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Equal(inner.Name, wrapper.Name); + Assert.Equal(inner.Description, wrapper.Description); + Assert.Equal(inner.JsonSchema, wrapper.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, wrapper.ReturnJsonSchema); + Assert.Same(inner.AdditionalProperties, wrapper.AdditionalProperties); + Assert.Equal(inner.ToString(), wrapper.ToString()); + } + + [Fact] + public void Namespace_DefaultIsNull() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Null(wrapper.Namespace); + } + + [Fact] + public void Namespace_Roundtrips() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); + + Assert.Equal("myNamespace", wrapper.Namespace); + } + + [Fact] + public void GetService_ReturnsSelf() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Same(wrapper, wrapper.GetService()); + } + + [Fact] + public void CreateToolSet_NullFunctions_Throws() + { + Assert.Throws("functions", () => SearchableAIFunctionDeclaration.CreateToolSet(null!)); + } + + [Fact] + public void CreateToolSet_ReturnsHostedToolSearchToolFirst_ThenWrappedFunctions() + { + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + var f2 = AIFunctionFactory.Create(() => 2, "F2"); + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1, f2]); + + Assert.Equal(3, tools.Count); + Assert.IsType(tools[0]); + Assert.Empty(tools[0].AdditionalProperties); + + var s1 = Assert.IsType(tools[1]); + Assert.Equal("F1", s1.Name); + Assert.Null(s1.Namespace); + + var s2 = Assert.IsType(tools[2]); + Assert.Equal("F2", s2.Name); + Assert.Null(s2.Namespace); + } + + [Fact] + public void CreateToolSet_WithNamespace_AppliesNamespaceToAll() + { + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], namespaceName: "ns"); + + var s1 = Assert.IsType(tools[1]); + Assert.Equal("ns", s1.Namespace); + } + + [Fact] + public void CreateToolSet_WithAdditionalProperties_PassesToHostedToolSearchTool() + { + var props = new Dictionary { ["key"] = "value" }; + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], toolSearchProperties: props); + + var toolSearch = Assert.IsType(tools[0]); + Assert.Same(props, toolSearch.AdditionalProperties); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs index 454fd74a731..23da7e1ae20 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -24,6 +24,7 @@ public void Constructor_PropsDefault() Assert.Null(tool.AllowedTools); Assert.Null(tool.ApprovalMode); Assert.Null(tool.Headers); + Assert.False(tool.DeferLoadingTools); } [Fact] @@ -96,6 +97,12 @@ public void Constructor_Roundtrips() Assert.NotNull(tool.Headers); Assert.Single(tool.Headers); Assert.Equal("value1", tool.Headers["X-Custom-Header"]); + + Assert.False(tool.DeferLoadingTools); + tool.DeferLoadingTools = true; + Assert.True(tool.DeferLoadingTools); + tool.DeferLoadingTools = false; + Assert.False(tool.DeferLoadingTools); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs new file mode 100644 index 00000000000..f3a32dc8c84 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedToolSearchToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedToolSearchTool(); + Assert.Equal("tool_search", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Constructor_AdditionalProperties_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + var tool = new HostedToolSearchTool(props); + + Assert.Equal("tool_search", tool.Name); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + var tool = new HostedToolSearchTool(null); + + Assert.Empty(tool.AdditionalProperties); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 7074ef51623..ed5af99e231 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -578,6 +578,39 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolConnector_ExtractsAuthTo Assert.Empty(tool.Headers); } + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolDeferLoadingTrue_PatchesDeferLoading() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + DeferLoadingTools = true + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + var json = ModelReaderWriter.Write(tool, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json); + Assert.Contains("true", json); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolDeferLoadingFalse_NoDeferLoading() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + DeferLoadingTools = false + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + var json = ModelReaderWriter.Write(tool, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json); + } + [Fact] public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull() { @@ -588,6 +621,73 @@ public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull() Assert.Null(result); } + [Fact] + public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesValidToolSearchTool() + { + var toolSearchTool = new HostedToolSearchTool(); + + var result = toolSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var json = ModelReaderWriter.Write(result, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("\"type\"", json); + Assert.Contains("tool_search", json); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesNewInstanceEachTime() + { + var result1 = new HostedToolSearchTool().AsOpenAIResponseTool(); + var result2 = new HostedToolSearchTool().AsOpenAIResponseTool(); + + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.NotSame(result1, result2); + } + + [Fact] + public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclaration_PatchesDeferLoading() + { + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var searchable = new SearchableAIFunctionDeclaration(inner); + + var result = ((AITool)searchable).AsOpenAIResponseTool(); + + Assert.NotNull(result); + var functionTool = Assert.IsType(result); + var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json); + Assert.Contains("true", json); + } + + [Fact] + public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclarationWithNamespace_PatchesNamespace() + { + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var searchable = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); + + var result = ((AITool)searchable).AsOpenAIResponseTool(); + + Assert.NotNull(result); + var functionTool = Assert.IsType(result); + var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("namespace", json); + Assert.Contains("myNamespace", json); + } + + [Fact] + public void AsOpenAIResponseTool_WithPlainAIFunction_NoDeferLoading() + { + var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + + var result = ((AITool)func).AsOpenAIResponseTool(); + + Assert.NotNull(result); + var functionTool = Assert.IsType(result); + var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json); + } + [Fact] public void AsOpenAIResponseTool_WithNullTool_ThrowsArgumentNullException() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index c98197e0b65..0435bb21a15 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -256,6 +256,39 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() } } + [ConditionalFact] + public async Task RemoteMCP_DeferLoadingTools() + { + SkipIfNotEnabled(); + + if (TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is not true) + { + throw new SkipTestException("Tool search requires gpt-5.4 or later."); + } + + ChatOptions chatOptions = new() + { + Tools = + [ + new HostedToolSearchTool(), + new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + DeferLoadingTools = true, + }, + ], + }; + + ChatResponse response = await ChatClient.GetResponseAsync( + "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", + chatOptions); + + Assert.NotNull(response); + Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); + Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); + } + [ConditionalFact] public async Task GetResponseAsync_BackgroundResponses() { @@ -754,4 +787,33 @@ public async Task ReasoningContent_Streaming_RoundtripsEncryptedContent() }); Assert.Contains("encrypted", ex.Message, StringComparison.OrdinalIgnoreCase); } + + [ConditionalFact] + public async Task UseToolSearch_WithDeferredFunctions() + { + SkipIfNotEnabled(); + + if (TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is not true) + { + throw new SkipTestException("Tool search requires gpt-5.4 or later."); + } + + AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather."); + AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time."); + + var response = await ChatClient.GetResponseAsync( + "What's the weather like? Just respond with the weather info, nothing else.", + new() + { + Tools = + [ + new HostedToolSearchTool(), + new SearchableAIFunctionDeclaration(getWeather), + new SearchableAIFunctionDeclaration(getTime), + ], + }); + + Assert.NotNull(response); + Assert.NotEmpty(response.Text); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index e62b0ecde57..bec6c3d47e5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -7105,5 +7105,383 @@ public async Task WebSearchTool_Streaming() var textContent = message.Contents.OfType().Single(); Assert.Equal(".NET 10 was officially released.", textContent.Text); } -} + [Fact] + public async Task ToolSearchTool_OnlyToolSearch_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [new HostedToolSearchTool()], + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_SearchableFunctionsDeferred_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "GetWeather", + "description": "Gets the weather.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "GetForecast", + "description": "Gets the forecast.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."); + var getForecast = AIFunctionFactory.Create(() => 42, "GetForecast", "Gets the forecast."); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool(), + new SearchableAIFunctionDeclaration(getWeather), + new SearchableAIFunctionDeclaration(getForecast), + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_MixedSearchableAndPlainFunctions_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "GetWeather", + "description": "Gets the weather.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "ImportantTool", + "description": "An important tool.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."); + var importantTool = AIFunctionFactory.Create(() => 42, "ImportantTool", "An important tool."); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool(), + new SearchableAIFunctionDeclaration(getWeather), + importantTool, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_NoFunctionTools_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "web_search" + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool(), + new HostedWebSearchTool(), + ], + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task McpTool_DeferLoadingTools_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp", + "defer_loading": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool(), + new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + DeferLoadingTools = true, + }, + ], + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } +}