diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 05a27940682..68e77b3744d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -2687,6 +2687,38 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.HostedToolSearchTool : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool(System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedToolSearchTool.AdditionalProperties { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedToolSearchTool.DeferredTools { get; set; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.HostedToolSearchTool.Name { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.HostedToolSearchTool.Namespace { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "sealed class Microsoft.Extensions.AI.HostedVectorStoreContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", 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..91ab9fc1f3a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs @@ -0,0 +1,70 @@ +// 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; + +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. +/// +/// +/// By default, when a is present in the tools list, all other deferrable tools +/// are treated as having deferred loading enabled. Use to control which tools have deferred loading +/// on a per-tool basis. +/// +/// +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; + + /// + /// Gets or sets the list of tool names for which deferred loading should be enabled. + /// + /// + /// + /// The default value is , which enables deferred loading for all deferrable tools in the tools list. + /// + /// + /// When non-null, only deferrable tools whose names appear in this list will have deferred loading enabled. + /// + /// + public IList? DeferredTools { get; set; } + + /// + /// Gets or sets the namespace name under which deferred tools should be grouped. + /// + /// + /// + /// When non-null, all deferred tools are wrapped inside a {"type":"namespace","name":"..."} + /// container. Non-deferred tools remain as top-level tools. + /// + /// + /// When (the default), deferred tools are sent as top-level tools + /// with defer_loading set individually. + /// + /// + public string? Namespace { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json index 120654b993f..56709da6107 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json @@ -100,7 +100,7 @@ "Stage": "Experimental" }, { - "Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool);", + "Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool, Microsoft.Extensions.AI.ChatOptions? options = null);", "Stage": "Experimental" } ] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 419b65aaecc..14a2fe81074 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -26,14 +26,19 @@ public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration funct /// Creates an OpenAI from an . /// The tool to convert. + /// + /// The that will be sent alongside this tool. When a + /// is present in , function tools may have defer_loading patched based on the + /// configuration. + /// /// 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)); + public static ResponseTool? AsOpenAIResponseTool(this AITool tool, ChatOptions? options = null) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool), options); /// /// 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 ae03b114380..63d22d72ced 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -17,6 +17,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 @@ -701,7 +702,7 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo (response is not null && response.Patch.TryGetValue("$.store"u8, out bool store) && !store); #pragma warning restore SCME0001 - internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null) + internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null, ToolSearchLookup? toolSearchLookup = null) { switch (tool) { @@ -709,7 +710,18 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo return rtat.Tool; case AIFunctionDeclaration aiFunction: - return ToResponseTool(aiFunction, options); + var functionTool = ToResponseTool(aiFunction, options); + if ((toolSearchLookup ??= ToolSearchLookup.Create(options?.Tools)).IsDeferred(aiFunction.Name)) + { + functionTool.Patch.Set("$.defer_loading"u8, "true"u8); + } + + 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 @@ -821,6 +833,11 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo break; } + if ((toolSearchLookup ??= ToolSearchLookup.Create(options?.Tools)).IsDeferred(mcpTool.ServerName)) + { + responsesMcpTool.Patch.Set("$.defer_loading"u8, "true"u8); + } + return responsesMcpTool; default: @@ -843,6 +860,34 @@ internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, Ch }; } + /// + /// Builds a {"type":"namespace"} from a name and set of tools. + /// The OpenAI .NET SDK doesn't expose a NamespaceTool type, so we construct the JSON manually. + /// + internal static ResponseTool ToNamespaceResponseTool(string name, IEnumerable namespacedTools) + { + using var stream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("type"u8, "namespace"u8); + writer.WriteString("name"u8, name); + + writer.WriteStartArray("tools"u8); + foreach (var namespacedTool in namespacedTools) + { + var toolData = ModelReaderWriter.Write(namespacedTool, ModelReaderWriterOptions.Json, OpenAIContext.Default); + using var doc = JsonDocument.Parse(toolData); + doc.RootElement.WriteTo(writer); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + return ModelReaderWriter.Read(BinaryData.FromBytes(stream.ToArray()), ModelReaderWriterOptions.Json, OpenAIContext.Default)!; + } + /// Creates a from a . private static ChatRole AsChatRole(MessageRole? role) => role switch @@ -926,14 +971,45 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out // Populate tools if there are any. if (options.Tools is { Count: > 0 } tools) { + ToolSearchLookup toolSearchLookup = ToolSearchLookup.Create(tools); + Dictionary>? namespaceGroups = null; + foreach (AITool tool in tools) { - if (ToResponseTool(tool, options) is { } responseTool) + if (ToResponseTool(tool, options, toolSearchLookup) is { } responseTool) { + // When a namespaced HostedToolSearchTool claims this deferred tool, + // collect it for later wrapping in a namespace container. + string? responseToolName = responseTool is FunctionTool ft ? ft.FunctionName + : responseTool is McpTool mcp ? mcp.ServerLabel + : null; + + if (responseToolName is not null + && toolSearchLookup.GetNamespace(responseToolName) is { } ns) + { + namespaceGroups ??= new(StringComparer.Ordinal); + if (!namespaceGroups.TryGetValue(ns, out var group)) + { + group = new(); + namespaceGroups[ns] = group; + } + + group.Add(responseTool); + continue; + } + result.Tools.Add(responseTool); } } + if (namespaceGroups is not null) + { + foreach (KeyValuePair> kvp in namespaceGroups) + { + result.Tools.Add(ToNamespaceResponseTool(kvp.Key, kvp.Value)); + } + } + if (result.Tools.Count > 0) { result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; @@ -969,6 +1045,98 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out return result; } + internal sealed class ToolSearchLookup + { + private static readonly ToolSearchLookup _empty = new(deferAll: false, deferredToolNames: [], namespacedToolNames: []); + private readonly bool _deferAll; + private readonly HashSet _deferredToolNames; + private readonly Dictionary _namespacedToolNames; + + private ToolSearchLookup(bool deferAll, HashSet deferredToolNames, Dictionary namespacedToolNames) + { + _deferAll = deferAll; + _deferredToolNames = deferredToolNames; + _namespacedToolNames = namespacedToolNames; + } + + public static ToolSearchLookup Create(IList? tools) + { + if (tools is not { Count: > 0 }) + { + return _empty; + } + + HashSet functionAndMcpToolNames = new( + tools.Select( + static tool => tool switch + { + AIFunctionDeclaration aiFunction => aiFunction.Name, + HostedMcpServerTool mcpTool => mcpTool.ServerName, + _ => null, + }) + .OfType(), + StringComparer.Ordinal); + + if (functionAndMcpToolNames.Count == 0) + { + return _empty; + } + + bool deferAll = false; + HashSet deferredToolNames = new(StringComparer.Ordinal); + Dictionary namespacedToolNames = new(StringComparer.Ordinal); + HashSet unclaimedToolNames = new(functionAndMcpToolNames, StringComparer.Ordinal); + + foreach (AITool tool in tools) + { + if (tool is not HostedToolSearchTool toolSearch) + { + continue; + } + + if (toolSearch.DeferredTools is not { } deferredTools) + { + deferAll = true; + deferredToolNames.UnionWith(functionAndMcpToolNames); + + if (toolSearch.Namespace is { } ns && unclaimedToolNames.Count > 0) + { + foreach (string toolName in unclaimedToolNames) + { + namespacedToolNames[toolName] = ns; + } + + unclaimedToolNames.Clear(); + } + + continue; + } + + foreach (string deferredTool in deferredTools) + { + if (!functionAndMcpToolNames.Contains(deferredTool)) + { + continue; + } + + _ = deferredToolNames.Add(deferredTool); + if (toolSearch.Namespace is { } ns && unclaimedToolNames.Remove(deferredTool)) + { + namespacedToolNames[deferredTool] = ns; + } + } + } + + return new(deferAll, deferredToolNames, namespacedToolNames); + } + + public bool IsDeferred(string toolName) => + _deferAll || _deferredToolNames.Contains(toolName); + + public string? GetNamespace(string toolName) => + _namespacedToolNames.TryGetValue(toolName, out string? ns) ? ns : null; + } + internal static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) => format switch { 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..103885440f8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs @@ -0,0 +1,72 @@ +// 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()); + Assert.Null(tool.DeferredTools); + Assert.Null(tool.Namespace); + } + + [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); + } + + [Fact] + public void DeferredTools_Roundtrips() + { + var tool = new HostedToolSearchTool + { + DeferredTools = ["GetWeather", "GetTime"] + }; + + Assert.NotNull(tool.DeferredTools); + Assert.Equal(2, tool.DeferredTools.Count); + Assert.Contains("GetWeather", tool.DeferredTools); + Assert.Contains("GetTime", tool.DeferredTools); + } + + [Fact] + public void Namespace_Roundtrips() + { + var tool = new HostedToolSearchTool + { + Namespace = "my_tools" + }; + + Assert.Equal("my_tools", tool.Namespace); + } + + [Fact] + public void Namespace_DefaultsToNull() + { + var tool = new HostedToolSearchTool(); + Assert.Null(tool.Namespace); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index d614e1ef5c2..fec2e4f052b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -588,6 +588,173 @@ 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_AllDeferred_WhenDeferredToolsNull() + { + var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var options = new ChatOptions { Tools = [new HostedToolSearchTool(), func] }; + + var result = func.AsOpenAIResponseTool(options); + + Assert.NotNull(result); + var json = ModelReaderWriter.Write(result, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json); + } + + [Fact] + public void AsOpenAIResponseTool_OnlyNamedDeferred_WhenDeferredToolsSpecified() + { + var func1 = AIFunctionFactory.Create(() => 42, "DeferredFunc", "Deferred"); + var func2 = AIFunctionFactory.Create(() => 0, "PlainFunc", "Plain"); + var options = new ChatOptions + { + Tools = [new HostedToolSearchTool { DeferredTools = ["DeferredFunc"] }, func1, func2] + }; + + var result1 = func1.AsOpenAIResponseTool(options); + var json1 = ModelReaderWriter.Write(result1!, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json1); + + var result2 = func2.AsOpenAIResponseTool(options); + var json2 = ModelReaderWriter.Write(result2!, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json2); + } + + [Fact] + public void AsOpenAIResponseTool_EmptyDeferredTools_NoDeferLoading() + { + var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var options = new ChatOptions + { + Tools = [new HostedToolSearchTool { DeferredTools = [] }, func] + }; + + var result = func.AsOpenAIResponseTool(options); + var json = ModelReaderWriter.Write(result!, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json); + } + + [Fact] + public void AsOpenAIResponseTool_MultipleToolSearchTools_DeferredByEither() + { + var func1 = AIFunctionFactory.Create(() => 42, "Func1", "First"); + var func2 = AIFunctionFactory.Create(() => 0, "Func2", "Second"); + var func3 = AIFunctionFactory.Create(() => 0, "Func3", "Third"); + var options = new ChatOptions + { + Tools = + [ + new HostedToolSearchTool { DeferredTools = ["Func1"] }, + new HostedToolSearchTool { DeferredTools = ["Func2"] }, + func1, func2, func3 + ] + }; + + var json1 = ModelReaderWriter.Write(func1.AsOpenAIResponseTool(options)!, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json1); + + var json2 = ModelReaderWriter.Write(func2.AsOpenAIResponseTool(options)!, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json2); + + // Func3 is not in either DeferredTools list + var json3 = ModelReaderWriter.Write(func3.AsOpenAIResponseTool(options)!, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json3); + } + + [Fact] + public void AsOpenAIResponseTool_NoToolSearch_NoDeferLoading() + { + var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + + var result = func.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var json = ModelReaderWriter.Write(result, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json); + } + + [Fact] + public void AsOpenAIResponseTool_McpTool_DeferredByToolSearch() + { + var mcpTool = new HostedMcpServerTool("my-mcp-server", "http://localhost:8000"); + var options = new ChatOptions + { + Tools = [new HostedToolSearchTool { DeferredTools = ["my-mcp-server"] }, mcpTool] + }; + + var result = mcpTool.AsOpenAIResponseTool(options); + var json = ModelReaderWriter.Write(result!, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json); + } + + [Fact] + public void AsOpenAIResponseTool_McpTool_NotDeferredWhenNotInList() + { + var mcpTool = new HostedMcpServerTool("my-mcp-server", "http://localhost:8000"); + var options = new ChatOptions + { + Tools = [new HostedToolSearchTool { DeferredTools = ["other-tool"] }, mcpTool] + }; + + var result = mcpTool.AsOpenAIResponseTool(options); + var json = ModelReaderWriter.Write(result!, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json); + } + + [Fact] + public void AsOpenAIResponseTool_McpTool_AllDeferredWhenDeferredToolsNull() + { + var mcpTool = new HostedMcpServerTool("my-mcp-server", "http://localhost:8000"); + var options = new ChatOptions + { + Tools = [new HostedToolSearchTool(), mcpTool] + }; + + var result = mcpTool.AsOpenAIResponseTool(options); + var json = ModelReaderWriter.Write(result!, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json); + } + + [Fact] + public void AsOpenAIResponseTool_NonDeferrableTool_IgnoresDeferredTools() + { + var codeTool = new HostedCodeInterpreterTool(); + var options = new ChatOptions + { + Tools = [new HostedToolSearchTool { DeferredTools = ["code_interpreter"] }, codeTool] + }; + + var result = codeTool.AsOpenAIResponseTool(options); + Assert.NotNull(result); + var json = ModelReaderWriter.Write(result, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json); + Assert.IsType(result); + } + [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..f1998f5af0c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -3,6 +3,7 @@ using System; using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -256,6 +257,55 @@ 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."); + } + + var mcpTool = new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + }; + + var mcpResponseTool = mcpTool.AsOpenAIResponseTool()!; +#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + mcpResponseTool.Patch.Set("$.defer_loading"u8, "true"u8); +#pragma warning restore SCME0001 + + ChatOptions chatOptions = new() + { + Tools = + [ + new HostedToolSearchTool(), + mcpResponseTool.AsAITool(), + ], + }; + + 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()); + + // Verify tool_search response items are present via RawRepresentation, + // since the OpenAI SDK doesn't expose dedicated types for them yet. + var allContents = response.Messages.SelectMany(m => m.Contents).ToList(); + var rawJsons = allContents + .Where(c => c.RawRepresentation is ResponseItem) + .Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString()) + .ToList(); + Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\"")); + Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\"")); + } + [ConditionalFact] public async Task GetResponseAsync_BackgroundResponses() { @@ -754,4 +804,91 @@ 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."); + + using var client = new FunctionInvokingChatClient(ChatClient); + var response = await client.GetResponseAsync( + "What's the weather like? Just respond with the weather info, nothing else.", + new() + { + Tools = + [ + new HostedToolSearchTool(), + getWeather, + getTime, + ], + }); + + Assert.NotNull(response); + Assert.NotEmpty(response.Text); + + // Verify tool_search response items occurred. + var rawJsons = response.Messages + .SelectMany(m => m.Contents) + .Where(c => c.RawRepresentation is ResponseItem) + .Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString()) + .ToList(); + Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\"")); + Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\"")); + } + + [ConditionalFact] + public async Task UseToolSearch_OnlyToolSearchNoFunctions() + { + 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."); + } + + // HostedToolSearchTool with no deferred tools — the API rejects this with 400 + // because tool_search requires at least one tool with defer_loading. + await Assert.ThrowsAsync(() => + ChatClient.GetResponseAsync( + "Say hello.", + new() + { + Tools = [new HostedToolSearchTool()], + })); + } + + [ConditionalFact] + public async Task UseToolSearch_WithNonDeferredFunctionsOnly() + { + 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."); + } + + // HostedToolSearchTool with DeferredTools explicitly set to empty — no tools are deferred. + // The API rejects this with 400 because tool_search requires at least one deferred tool. + AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather."); + + await Assert.ThrowsAsync(() => + ChatClient.GetResponseAsync( + "What's the weather? Reply with just the weather info.", + new() + { + Tools = + [ + new HostedToolSearchTool { DeferredTools = [] }, + getWeather, + ], + })); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 8f0aedb3d59..07b26c4b631 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -7194,5 +7194,1003 @@ 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(), + getWeather, + getForecast, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_MixedDeferredAndNonDeferredFunctions_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-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-5.4-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-5.4-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 { DeferredTools = ["GetWeather"] }, + 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-5.4-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-5.4-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-5.4-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 ToolSearchTool_NamespaceGrouping_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "ImportantTool", + "description": "An important tool.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + }, + { + "type": "namespace", + "name": "crm", + "tools": [ + { + "type": "function", + "name": "GetCustomer", + "description": "Gets a customer.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "ListOrders", + "description": "Lists orders.", + "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-5.4-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-5.4-mini"); + + var getCustomer = AIFunctionFactory.Create(() => 42, "GetCustomer", "Gets a customer."); + var listOrders = AIFunctionFactory.Create(() => 42, "ListOrders", "Lists orders."); + var importantTool = AIFunctionFactory.Create(() => 42, "ImportantTool", "An important tool."); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { Namespace = "crm", DeferredTools = ["GetCustomer", "ListOrders"] }, + getCustomer, + listOrders, + importantTool, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_NamespaceAllDeferred_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "namespace", + "name": "utilities", + "tools": [ + { + "type": "function", + "name": "GetWeather", + "description": "Gets the weather.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "GetTime", + "description": "Gets the time.", + "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-5.4-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-5.4-mini"); + + var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."); + var getTime = AIFunctionFactory.Create(() => 42, "GetTime", "Gets the time."); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { Namespace = "utilities" }, + getWeather, + getTime, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_MultipleNamespaces_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "tool_search" + }, + { + "type": "namespace", + "name": "crm", + "tools": [ + { + "type": "function", + "name": "GetCustomer", + "description": "Gets a customer.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } + ] + }, + { + "type": "namespace", + "name": "weather", + "tools": [ + { + "type": "function", + "name": "GetWeather", + "description": "Gets the weather.", + "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-5.4-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-5.4-mini"); + + var getCustomer = AIFunctionFactory.Create(() => 42, "GetCustomer", "Gets a customer."); + var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { Namespace = "crm", DeferredTools = ["GetCustomer"] }, + new HostedToolSearchTool { Namespace = "weather", DeferredTools = ["GetWeather"] }, + getCustomer, + getWeather, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_SameNamespaceMerged_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "tool_search" + }, + { + "type": "namespace", + "name": "crm", + "tools": [ + { + "type": "function", + "name": "GetCustomer", + "description": "Gets a customer.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "ListOrders", + "description": "Lists orders.", + "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-5.4-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-5.4-mini"); + + var getCustomer = AIFunctionFactory.Create(() => 42, "GetCustomer", "Gets a customer."); + var listOrders = AIFunctionFactory.Create(() => 42, "ListOrders", "Lists orders."); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { Namespace = "crm", DeferredTools = ["GetCustomer"] }, + new HostedToolSearchTool { Namespace = "crm", DeferredTools = ["ListOrders"] }, + getCustomer, + listOrders, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_ToolClaimedByFirstNamespace_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "tool_search" + }, + { + "type": "namespace", + "name": "primary", + "tools": [ + { + "type": "function", + "name": "SharedTool", + "description": "A shared tool.", + "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-5.4-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-5.4-mini"); + + var sharedTool = AIFunctionFactory.Create(() => 42, "SharedTool", "A shared tool."); + + // SharedTool is claimed by both namespaces — first one ("primary") wins. + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { Namespace = "primary" }, + new HostedToolSearchTool { Namespace = "secondary" }, + sharedTool, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_McpToolNamespaceGrouping_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "LocalFunc", + "description": "A local function.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + }, + { + "type": "namespace", + "name": "remote", + "tools": [ + { + "type": "mcp", + "server_label": "my-mcp-server", + "server_url": "http://localhost:8000/", + "defer_loading": true + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-5.4-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-5.4-mini"); + + var mcpTool = new HostedMcpServerTool("my-mcp-server", "http://localhost:8000"); + var localFunc = AIFunctionFactory.Create(() => 42, "LocalFunc", "A local function."); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { Namespace = "remote", DeferredTools = ["my-mcp-server"] }, + mcpTool, + localFunc, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_NonDeferrableToolStaysTopLevel_NonStreaming() + { + const string Input = """ + { + "model": "gpt-5.4-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "code_interpreter", + "container": { + "type": "auto" + } + }, + { + "type": "function", + "name": "LocalFunc", + "description": "A local function.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-5.4-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-5.4-mini"); + + var codeTool = new HostedCodeInterpreterTool(); + var localFunc = AIFunctionFactory.Create(() => 42, "LocalFunc", "A local function."); + + // code_interpreter is not deferrable — it stays top-level even when listed in DeferredTools with a namespace. + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { Namespace = "sandbox", DeferredTools = ["code_interpreter"] }, + codeTool, + localFunc, + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + +} \ No newline at end of file