diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj
index 78364f4a30b..baf2152635c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj
@@ -12,6 +12,7 @@
+ true
true
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs
index 0d5741984e7..abd78fe4d91 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs
@@ -74,4 +74,6 @@ public sealed class OpenAIResponseRequestInfo
/// .
///
public ChatToolMode? ToolChoice { get; set; }
+
+ internal bool HasToolChoice { get; set; }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs
index 63a550f2c1e..81b18c42a62 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs
@@ -2,6 +2,11 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
@@ -18,26 +23,80 @@ public sealed class OpenAIResponsesMapOptions
///
///
///
- /// By default this is set to , which throws when the request
+ /// By default this uses , which throws when the request
/// carries any setting that would otherwise be mapped onto the agent (for example
/// temperature, instructions, tools or tool_choice). This prevents a
/// caller from silently overriding the configuration of a self-contained agent.
+ /// Enabling selects a default mapping that
+ /// forwards function declarations while continuing to reject other unsupported settings.
///
///
/// Hosting developers that want to honor specific request settings can supply their own callback
/// that maps the desired fields onto an (or a subclass such as
/// ), and may choose to throw, map, or ignore any field.
+ /// A custom callback receives all request settings, including the complete
+ /// collection, and replaces the default mapping.
+ /// Its result is used unchanged, regardless of .
/// Returning runs the agent with its own configuration only.
///
///
public Func RunOptionsFactory
{
- get;
+ get
+ {
+#pragma warning disable MAAI001
+ return field ?? (this.DangerouslyAllowClientFunctionTools
+ ? MapClientFunctionTools
+ : RejectRequestSettings);
+#pragma warning restore MAAI001
+ }
set
{
field = Throw.IfNull(value);
}
- } = RejectRequestSettings;
+ }
+
+ ///
+ /// Gets or sets whether the default mapping forwards client-provided function declarations
+ /// in the agent's run options.
+ ///
+ ///
+ ///
+ /// This setting is dangerous because client-provided function names, descriptions, and schemas
+ /// can change which tools the model chooses. The declarations do not contain executable code.
+ /// The downstream chat client and provider determine how function calls are handled.
+ ///
+ ///
+ /// A client function may cause the model to choose it instead of a function configured by the
+ /// hosted agent developer, even when their names do not conflict. Function arguments and any data
+ /// included in those arguments are then returned to the client.
+ ///
+ ///
+ /// The default is , which leaves client-provided tools subject to
+ /// and its default behavior. The request's
+ /// tool_choice is not enabled by this setting and remains controlled by
+ /// .
+ ///
+ ///
+ /// With the default mapping, accepted function declarations are converted to
+ /// ChatClientAgentRunOptions.ChatOptions.Tools. Other tool types and unsupported request
+ /// settings are rejected. This setting has no effect when a custom
+ /// is supplied; that callback owns the entire mapping.
+ ///
+ ///
+ /// The default mapping produces . The hosting layer
+ /// does not require a particular agent implementation. Agents that do not consume these options
+ /// may ignore the mapped functions; enabling this setting does not add function support to them.
+ ///
+ ///
+ /// Function names are not checked for conflicts or deduplicated. The downstream chat client and
+ /// provider determine whether duplicate names are accepted and which function is selected.
+ /// The hosting layer does not guarantee that a hosted function takes precedence over a client
+ /// declaration. The agent's parallel tool calling configuration is not changed.
+ ///
+ ///
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public bool DangerouslyAllowClientFunctionTools { get; set; }
///
/// The default implementation. Throws a
@@ -55,6 +114,30 @@ public sealed class OpenAIResponsesMapOptions
{
ArgumentNullException.ThrowIfNull(request);
+ ThrowIfUnsupportedRequestSettings(request, request.Tools);
+ return null;
+ }
+
+ private static AgentRunOptions? MapClientFunctionTools(OpenAIResponseRequestInfo request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (request.Tools is not { Count: > 0 } tools)
+ {
+ return RejectRequestSettings(request);
+ }
+
+ (List? clientTools, List? remainingTools) = tools.ConvertClientFunctionTools();
+ ThrowIfUnsupportedRequestSettings(request, remainingTools);
+ return clientTools is { Count: > 0 }
+ ? new ChatClientAgentRunOptions(new ChatOptions { Tools = clientTools })
+ : null;
+ }
+
+ private static void ThrowIfUnsupportedRequestSettings(
+ OpenAIResponseRequestInfo request,
+ IReadOnlyList? tools)
+ {
List? unsupported = null;
void LocalAdd(string name) => (unsupported ??= []).Add(name);
@@ -78,12 +161,12 @@ public sealed class OpenAIResponsesMapOptions
LocalAdd("instructions");
}
- if (request.Tools is { Count: > 0 })
+ if (tools is { Count: > 0 })
{
LocalAdd("tools");
}
- if (request.ToolChoice is not null)
+ if (request.HasToolChoice || request.ToolChoice is not null)
{
LocalAdd("tool_choice");
}
@@ -94,7 +177,5 @@ public sealed class OpenAIResponsesMapOptions
$"The following request setting(s) are not supported by this agent endpoint: {string.Join(", ", unsupported)}. " +
"Configure an OpenAIResponsesMapOptions.RunOptionsFactory to map these settings onto the agent if they should be honored.");
}
-
- return null;
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
index 959209a0aa4..5b01cc1394a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
@@ -17,13 +17,15 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
internal sealed class AIAgentResponseExecutor : IResponseExecutor
{
private readonly AIAgent _agent;
- private readonly Func _runOptionsFactory;
+ private readonly OpenAIResponsesMapOptions _mapOptions;
- public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOptions = null)
+ public AIAgentResponseExecutor(
+ AIAgent agent,
+ OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
this._agent = agent;
- this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory;
+ this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}
public ValueTask ValidateRequestAsync(
@@ -35,9 +37,9 @@ public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOpti
{
try
{
- // Invoke the factory during validation so that unsupported request settings are surfaced
+ // Map options during validation so that unsupported request settings are surfaced
// as a clean request error rather than an unhandled exception during execution.
- _ = this._runOptionsFactory(request.ToRequestInfo());
+ _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
return null;
}
catch (NotSupportedException ex)
@@ -58,7 +60,7 @@ public async IAsyncEnumerable ExecuteAsync(
{
// The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
// request settings are mapped onto the agent run. By default no request setting is mapped.
- AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo());
+ AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
// Convert input to chat messages, prepending conversation history if available
var messages = new List();
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
index bc51e74dc76..687c121be25 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
@@ -21,7 +21,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
- private readonly Func _runOptionsFactory;
+ private readonly OpenAIResponsesMapOptions _mapOptions;
///
/// Initializes a new instance of the class.
@@ -39,7 +39,7 @@ public HostedAgentResponseExecutor(
this._serviceProvider = serviceProvider;
this._logger = logger;
- this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory;
+ this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}
///
@@ -83,7 +83,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti
// exception during execution.
try
{
- _ = this._runOptionsFactory(request.ToRequestInfo());
+ _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
}
catch (NotSupportedException ex)
{
@@ -109,7 +109,7 @@ public async IAsyncEnumerable ExecuteAsync(
// The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
// request settings are mapped onto the agent run. By default no request setting is mapped.
- AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo());
+ AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
var messages = new List();
if (conversationHistory is not null)
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs
index 46b4532638b..e38b604fb91 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs
@@ -9,6 +9,8 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
internal static class OpenAIResponseRequestInfoBuilder
{
+ private static readonly JsonElement s_emptyJson = JsonElement.Parse("{}");
+
public static OpenAIResponseRequestInfo ToRequestInfo(this CreateResponse request) => new()
{
Temperature = request.Temperature,
@@ -18,8 +20,94 @@ internal static class OpenAIResponseRequestInfoBuilder
Model = request.Model,
Tools = request.Tools is { Count: > 0 } tools ? new List(tools) : null,
ToolChoice = request.ToolChoice?.ToChatToolMode(),
+ HasToolChoice = request.ToolChoice is not null,
};
+ internal static (List? ClientTools, List? RemainingTools)
+ ConvertClientFunctionTools(this IReadOnlyList tools)
+ {
+ List? clientTools = null;
+ List? remainingTools = null;
+
+ foreach (JsonElement tool in tools)
+ {
+ if (tool.ToFunctionTool() is { } functionTool)
+ {
+ (clientTools ??= []).Add(functionTool);
+ }
+ else
+ {
+ (remainingTools ??= []).Add(tool);
+ }
+ }
+
+ return (clientTools, remainingTools);
+ }
+
+ private static ClientAIFunctionDeclaration? ToFunctionTool(this JsonElement tool)
+ {
+ if (tool.ValueKind != JsonValueKind.Object ||
+ !tool.TryGetProperty("type", out JsonElement type) ||
+ type.ValueKind != JsonValueKind.String ||
+ type.GetString() != "function" ||
+ !tool.TryGetProperty("name", out JsonElement name) ||
+ name.ValueKind != JsonValueKind.String ||
+ name.GetString() is not { Length: > 0 } functionName)
+ {
+ return null;
+ }
+
+ JsonElement parameters = tool.TryGetProperty("parameters", out JsonElement requestParameters) &&
+ requestParameters.ValueKind == JsonValueKind.Object
+ ? requestParameters
+ : s_emptyJson;
+
+ string? description = tool.TryGetProperty("description", out JsonElement requestDescription) &&
+ requestDescription.ValueKind == JsonValueKind.String
+ ? requestDescription.GetString()
+ : null;
+
+ AIFunctionDeclaration function = AIFunctionFactory.CreateDeclaration(functionName, description, parameters);
+ bool? strict = tool.TryGetProperty("strict", out JsonElement requestStrict) &&
+ requestStrict.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? requestStrict.GetBoolean()
+ : null;
+
+ return new ClientAIFunctionDeclaration(function, strict);
+ }
+
+ internal sealed class ClientAIFunctionDeclaration : AIFunctionDeclaration
+ {
+ private readonly AIFunctionDeclaration _innerFunction;
+
+ public ClientAIFunctionDeclaration(AIFunctionDeclaration innerFunction, bool? strict)
+ {
+ this._innerFunction = innerFunction;
+ var additionalProperties = new Dictionary();
+ foreach (KeyValuePair property in innerFunction.AdditionalProperties)
+ {
+ additionalProperties.Add(property.Key, property.Value);
+ }
+
+ if (strict is not null)
+ {
+ additionalProperties["strict"] = strict;
+ }
+
+ this.AdditionalProperties = additionalProperties;
+ }
+
+ public override string Name => this._innerFunction.Name;
+
+ public override string Description => this._innerFunction.Description;
+
+ public override JsonElement JsonSchema => this._innerFunction.JsonSchema;
+
+ public override JsonElement? ReturnJsonSchema => this._innerFunction.ReturnJsonSchema;
+
+ public override IReadOnlyDictionary AdditionalProperties { get; }
+ }
+
///
/// Maps an OpenAI Responses tool_choice value onto its equivalent.
///
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs
new file mode 100644
index 00000000000..f948a7d85c6
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using OpenAI;
+using Shared.IntegrationTests;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests;
+
+///
+/// Live integration tests for client-provided function tools passed through OpenAI Responses hosting.
+///
+public sealed class OpenAIResponsesClientFunctionToolsLiveTests
+{
+ private const string ClientRequestJson = """
+ {
+ "input": "Call get_weather for Valencia. Do not answer without calling the function.",
+ "tools": [
+ {
+ "type": "function",
+ "name": "get_weather",
+ "description": "Return weather from the client application.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location": { "type": "string" }
+ },
+ "required": [ "location" ],
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ ]
+ }
+ """;
+
+ private static string? ApiKey => Environment.GetEnvironmentVariable(TestSettings.OpenAIApiKey);
+
+ private static string ModelName =>
+ Environment.GetEnvironmentVariable(TestSettings.OpenAIChatModelName) ?? "gpt-4o-mini";
+
+ [Fact]
+ public async Task AllowedClientFunction_ReturnsFunctionCallAsync()
+ {
+ // Arrange
+ Assert.SkipWhen(
+ string.IsNullOrEmpty(ApiKey),
+ "OPENAI_API_KEY is not configured; skipping live client function tool test.");
+
+ using IChatClient chatClient = new OpenAIClient(ApiKey).GetResponsesClient().AsIChatClient(ModelName);
+ var agent = new ChatClientAgent(
+ chatClient,
+ instructions: "For every weather request, call get_weather before answering.",
+ name: "weather-agent");
+ JsonElement requestBody = ParseBody(ClientRequestJson);
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = true };
+#pragma warning restore MAAI001
+
+ // Act
+ OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(requestBody, mapOptions);
+ AgentResponse response = await agent.RunAsync(run.Messages, options: run.Options);
+
+ // Assert
+ FunctionCallContent functionCall = Assert.Single(
+ response.Messages.SelectMany(message => message.Contents).OfType());
+ Assert.Equal("get_weather", functionCall.Name);
+ }
+
+ private static JsonElement ParseBody(string json)
+ {
+ using JsonDocument document = JsonDocument.Parse(json);
+ return document.RootElement.Clone();
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs
index b0ce53eaa08..01bb755df2f 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs
@@ -146,6 +146,114 @@ public async Task Responses_DefaultEndpoint_RejectsRequestWithSettingsAsync()
Assert.Contains("temperature", body, StringComparison.Ordinal);
}
+ [Fact]
+ public async Task Responses_DefaultEndpoint_RejectsClientFunctionToolsAsync()
+ {
+ // Arrange
+ using var app = await CreateResponsesServerAsync("reject-tools-agent", mapOptions: null);
+ using HttpClient client = GetClient(app);
+ using var content = new StringContent(
+ """{"input":"hello","tools":[{"type":"function","name":"client_function"}]}""",
+ Encoding.UTF8,
+ "application/json");
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(
+ new Uri("/reject-tools-agent/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ string body = await response.Content.ReadAsStringAsync();
+ Assert.Contains("tools", body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowToolChoiceAsync()
+ {
+ // Arrange
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true
+ };
+#pragma warning restore MAAI001
+ using var app = await CreateResponsesServerAsync("reject-tool-choice-agent", mapOptions);
+ using HttpClient client = GetClient(app);
+ using var content = new StringContent(
+ """
+ {
+ "input": "hello",
+ "tools": [
+ { "type": "function", "name": "client_function" }
+ ],
+ "tool_choice": "required"
+ }
+ """,
+ Encoding.UTF8,
+ "application/json");
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(
+ new Uri("/reject-tool-choice-agent/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ string body = await response.Content.ReadAsStringAsync();
+ Assert.Contains("tool_choice", body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowOtherToolTypesAsync()
+ {
+ // Arrange
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true
+ };
+#pragma warning restore MAAI001
+ using var app = await CreateResponsesServerAsync("reject-hosted-tool-agent", mapOptions);
+ using HttpClient client = GetClient(app);
+ using var content = new StringContent(
+ """{"input":"hello","tools":[{"type":"web_search"}]}""",
+ Encoding.UTF8,
+ "application/json");
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(
+ new Uri("/reject-hosted-tool-agent/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ string body = await response.Content.ReadAsStringAsync();
+ Assert.Contains("tools", body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Responses_DefaultEndpoint_RejectsUnrecognizedToolChoiceAsync()
+ {
+ // Arrange
+ using var app = await CreateResponsesServerAsync("reject-unknown-tool-choice-agent", mapOptions: null);
+ using HttpClient client = GetClient(app);
+ using var content = new StringContent(
+ """{"input":"hello","tool_choice":"unsupported"}""",
+ Encoding.UTF8,
+ "application/json");
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(
+ new Uri("/reject-unknown-tool-choice-agent/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ string body = await response.Content.ReadAsStringAsync();
+ Assert.Contains("tool_choice", body, StringComparison.Ordinal);
+ }
+
[Fact]
public async Task Responses_ConfiguredEndpoint_HonorsRequestSettingsAsync()
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs
index bef3325dd7d..7e02fcff475 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs
@@ -92,6 +92,88 @@ public void ToRequestInfo_NoToolChoice_MapsToNull()
Assert.Null(info.ToolChoice);
}
+ [Fact]
+ public void ToRequestInfo_PreservesFunctionToolAsRawTool()
+ {
+ // Arrange
+ CreateResponse request = new()
+ {
+ Input = "hello",
+ Tools =
+ [
+ ParseElement(
+ """
+ {
+ "type": "function",
+ "name": "get_weather",
+ "description": "Retrieves current weather.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location": { "type": "string" }
+ },
+ "required": [ "location" ]
+ },
+ "strict": true
+ }
+ """)
+ ]
+ };
+
+ // Act
+ OpenAIResponseRequestInfo info = request.ToRequestInfo();
+
+ // Assert
+ JsonElement function = Assert.Single(info.Tools!);
+ Assert.Equal("function", function.GetProperty("type").GetString());
+ Assert.Equal("get_weather", function.GetProperty("name").GetString());
+ Assert.Equal("Retrieves current weather.", function.GetProperty("description").GetString());
+ Assert.True(function.GetProperty("parameters").GetProperty("properties").TryGetProperty("location", out _));
+ Assert.True(function.GetProperty("strict").GetBoolean());
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void ConvertClientFunctionTools_PreservesMetadataAndStrict(bool? strict)
+ {
+ // Arrange
+ string strictProperty = strict.HasValue ? $",\"strict\":{(strict.Value ? "true" : "false")}" : string.Empty;
+ JsonElement[] tools =
+ [
+ ParseElement(
+ $$$$"""
+ {
+ "type":"function",
+ "name":"get_weather",
+ "description":"Retrieves current weather.",
+ "parameters":{"type":"object","properties":{"location":{"type":"string"}}}
+ {{{{strictProperty}}}}
+ }
+ """)
+ ];
+
+ // Act
+ var (clientTools, remainingTools) = tools.ConvertClientFunctionTools();
+
+ // Assert
+ var function = Assert.IsAssignableFrom(Assert.Single(clientTools!));
+ Assert.Null(remainingTools);
+ Assert.Equal("get_weather", function.Name);
+ Assert.Equal("Retrieves current weather.", function.Description);
+ Assert.Equal("string", function.JsonSchema.GetProperty("properties").GetProperty("location").GetProperty("type").GetString());
+ Assert.Null(function.ReturnJsonSchema);
+ if (strict.HasValue)
+ {
+ Assert.Equal(strict.Value, Assert.IsType(function.AdditionalProperties["strict"]));
+ }
+ else
+ {
+ Assert.False(function.AdditionalProperties.ContainsKey("strict"));
+ }
+ }
+
private static CreateResponse CreateRequestWithToolChoice(string toolChoiceJson)
{
using JsonDocument document = JsonDocument.Parse(toolChoiceJson);
@@ -101,4 +183,10 @@ private static CreateResponse CreateRequestWithToolChoice(string toolChoiceJson)
ToolChoice = document.RootElement.Clone(),
};
}
+
+ private static JsonElement ParseElement(string json)
+ {
+ using JsonDocument document = JsonDocument.Parse(json);
+ return document.RootElement.Clone();
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentCompatibilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentCompatibilityTests.cs
new file mode 100644
index 00000000000..104bf86d74b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentCompatibilityTests.cs
@@ -0,0 +1,194 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
+
+///
+/// Tests permissive client function mapping across agent implementations.
+///
+public sealed class OpenAIResponsesAgentCompatibilityTests
+{
+ [Theory]
+ [InlineData(false, "direct")]
+ [InlineData(true, "direct")]
+ [InlineData(false, "wrapped")]
+ [InlineData(true, "wrapped")]
+ [InlineData(false, "opaque")]
+ [InlineData(true, "opaque")]
+ public async Task DefaultMapping_ForwardsFunctionsWithoutRequiringDiscoverableChatClientAgentAsync(bool resolveByName, string agentKind)
+ {
+ // Arrange
+ using var chatClient = new TestHelpers.SimpleMockChatClient();
+ ChatClientAgent inner = chatClient.AsAIAgent(name: "test-agent");
+ AIAgent agent = agentKind switch
+ {
+ "direct" => inner,
+ "wrapped" => new TestDelegatingAgent(inner),
+ _ => new OpaqueAgent(inner)
+ };
+ await using WebApplication app = await CreateServerAsync(agent, resolveByName);
+ using HttpClient client = app.GetTestClient();
+ using var content = CreateRequest(withTools: true);
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions!.Tools!));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task DefaultMapping_AgentIgnoringChatOptions_CanRespondAsync(bool resolveByName)
+ {
+ // Arrange
+ var agent = new NonChatAgent();
+ await using WebApplication app = await CreateServerAsync(agent, resolveByName);
+ using HttpClient client = app.GetTestClient();
+ using var content = CreateRequest(withTools: true);
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content);
+
+ // Assert
+ Assert.Null(agent.GetService());
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Contains("Response without function support.", await response.Content.ReadAsStringAsync());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task DefaultMapping_WithoutClientFunctions_DoesNotRequireChatClientAgentAsync(bool resolveByName)
+ {
+ // Arrange
+ using var chatClient = new TestHelpers.SimpleMockChatClient();
+ AIAgent agent = new OpaqueAgent(chatClient.AsAIAgent(name: "test-agent"));
+ await using WebApplication app = await CreateServerAsync(agent, resolveByName);
+ using HttpClient client = app.GetTestClient();
+ using var content = CreateRequest(withTools: false);
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CustomMapping_DoesNotRequireChatClientAgentAsync(bool resolveByName)
+ {
+ // Arrange
+ using var chatClient = new TestHelpers.SimpleMockChatClient();
+ AIAgent agent = new OpaqueAgent(chatClient.AsAIAgent(name: "test-agent"));
+ AIFunction tool = AIFunctionFactory.Create(() => "server", "server_function");
+ await using WebApplication app = await CreateServerAsync(
+ agent,
+ resolveByName,
+ _ => new ChatClientAgentRunOptions(new ChatOptions { Tools = [tool] }));
+ using HttpClient client = app.GetTestClient();
+ using var content = CreateRequest(withTools: true);
+
+ // Act
+ using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Same(tool, Assert.Single(chatClient.LastChatOptions!.Tools!));
+ }
+
+ private static async Task CreateServerAsync(
+ AIAgent agent,
+ bool resolveByName,
+ Func? factory = null)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.AddOpenAIResponses();
+ builder.Services.AddKeyedSingleton("test-agent", agent);
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = true };
+#pragma warning restore MAAI001
+ if (factory is not null)
+ {
+ mapOptions.RunOptionsFactory = factory;
+ }
+
+ builder.Services.AddSingleton(mapOptions);
+ WebApplication app = builder.Build();
+ if (resolveByName)
+ {
+ app.MapOpenAIResponses();
+ }
+ else
+ {
+ app.MapOpenAIResponses(agent, "/v1/responses", mapOptions);
+ }
+
+ await app.StartAsync();
+ return app;
+ }
+
+ private static StringContent CreateRequest(bool withTools) => new(
+ withTools
+ ? """{"agent":{"name":"test-agent"},"input":"hello","tools":[{"type":"function","name":"client_function"}]}"""
+ : """{"agent":{"name":"test-agent"},"input":"hello"}""",
+ Encoding.UTF8,
+ "application/json");
+
+ private sealed class TestDelegatingAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent);
+
+ private sealed class OpaqueAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent)
+ {
+ public override object? GetService(Type serviceType, object? serviceKey = null) =>
+ serviceType == typeof(ChatClientAgent) ? null : base.GetService(serviceType, serviceKey);
+ }
+
+ private sealed class NonChatAgent : AIAgent
+ {
+ public override string Name => "test-agent";
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
+ new(new TestSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response without function support.")));
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.CompletedTask;
+ yield return new AgentResponseUpdate(ChatRole.Assistant, "Response without function support.");
+ }
+
+ private sealed class TestSession : AgentSession;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs
index 4612ec35844..c1f622fba22 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs
@@ -940,6 +940,245 @@ public async Task CreateResponse_FunctionCall_ReturnsCorrectlyAsync()
Assert.NotNull(response.Id);
}
+ [Fact]
+ public async Task CreateResponse_WithAllowedClientFunctionTool_ForwardsToolAndReturnsFunctionCallAsync()
+ {
+ // Arrange
+ const string AgentName = "request-function-tool-agent";
+ var chatClient = new TestHelpers.FunctionCallMockChatClient(
+ "get_weather",
+ """{"location":"Valencia, Spain","units":"celsius"}""");
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true
+ };
+#pragma warning restore MAAI001
+
+ this._httpClient = await this.CreateTestServerWithCustomClientAsync(
+ agentName: AgentName,
+ instructions: "You are a helpful assistant.",
+ chatClient,
+ mapOptions);
+
+ using var content = new StringContent(
+ """
+ {
+ "input": "What's the current weather in Valencia?",
+ "tools": [
+ {
+ "type": "function",
+ "name": "get_weather",
+ "description": "Retrieves current weather for the given location.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location": { "type": "string" },
+ "units": { "type": "string", "enum": [ "celsius", "fahrenheit" ] }
+ },
+ "required": [ "location", "units" ],
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ ]
+ }
+ """,
+ Encoding.UTF8,
+ "application/json");
+
+ // Act
+ using HttpResponseMessage httpResponse = await this._httpClient.PostAsync(
+ new Uri($"/{AgentName}/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.True(httpResponse.IsSuccessStatusCode, $"Response status: {httpResponse.StatusCode}");
+ Assert.NotNull(chatClient.LastChatOptions);
+ Assert.Null(chatClient.LastChatOptions.AllowMultipleToolCalls);
+ Assert.Null(chatClient.LastChatOptions.ToolMode);
+ AIFunctionDeclaration tool = Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions.Tools!));
+ Assert.Equal("get_weather", tool.Name);
+ Assert.Equal("Retrieves current weather for the given location.", tool.Description);
+ Assert.True(tool.JsonSchema.GetProperty("properties").TryGetProperty("units", out _));
+ Assert.True(Assert.IsType(tool.AdditionalProperties["strict"]));
+
+ using System.Text.Json.JsonDocument document =
+ System.Text.Json.JsonDocument.Parse(await httpResponse.Content.ReadAsStringAsync());
+ System.Text.Json.JsonElement output = Assert.Single(document.RootElement.GetProperty("output").EnumerateArray());
+ Assert.Equal("function_call", output.GetProperty("type").GetString());
+ Assert.Equal("get_weather", output.GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public async Task CreateResponse_WithHostedAIFunction_ForwardsAndExecutesToolAsync()
+ {
+ // Arrange
+ const string AgentName = "hosted-function-tool-agent";
+ const string FunctionName = "get_weather";
+ int invocationCount = 0;
+ var chatClient = new TestHelpers.FunctionToolExecutingMockChatClient(FunctionName);
+ AIFunction function = AIFunctionFactory.Create(GetWeather, FunctionName);
+
+ this._httpClient = await this.CreateTestServerWithHostedToolAsync(
+ AgentName,
+ chatClient,
+ function);
+
+ using var content = new StringContent(
+ """{"input":"What's the weather in Valencia?"}""",
+ Encoding.UTF8,
+ "application/json");
+
+ // Act
+ using HttpResponseMessage response = await this._httpClient.PostAsync(
+ new Uri($"/{AgentName}/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(1, invocationCount);
+ Assert.Equal("Sunny in Valencia", chatClient.FunctionResult);
+ Assert.Contains(
+ chatClient.FirstRequestOptions!.Tools!,
+ tool => tool is AIFunction candidate && candidate.Name == FunctionName);
+
+ string GetWeather(string location)
+ {
+ invocationCount++;
+ return $"Sunny in {location}";
+ }
+ }
+
+ [Fact]
+ public async Task CreateResponse_WithHostedMcpTool_ForwardsToolToResponsesProviderAsync()
+ {
+ // Arrange
+ var chatClient = new TestHelpers.SimpleMockChatClient("Documentation found.");
+#pragma warning disable MEAI001
+ var hostedMcpTool = new HostedMcpServerTool(
+ serverName: "test_mcp",
+ serverAddress: "https://example.test/mcp")
+ {
+ ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
+ };
+#pragma warning restore MEAI001
+ const string AgentName = "hosted-mcp-tool-agent";
+
+ this._httpClient = await this.CreateTestServerWithHostedToolAsync(
+ AgentName,
+ chatClient,
+ hostedMcpTool);
+
+ using var content = new StringContent(
+ """{"input":"Search the documentation."}""",
+ Encoding.UTF8,
+ "application/json");
+
+ // Act
+ using HttpResponseMessage response = await this._httpClient.PostAsync(
+ new Uri($"/{AgentName}/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ HostedMcpServerTool forwardedTool =
+ Assert.IsType(Assert.Single(chatClient.LastChatOptions!.Tools!));
+ Assert.Equal("test_mcp", forwardedTool.ServerName);
+ Assert.Equal("https://example.test/mcp", forwardedTool.ServerAddress);
+ }
+
+ [Fact]
+ public async Task CreateResponse_WithClientFunctionNamedMcp_DoesNotReplaceHostedMcpToolAsync()
+ {
+ // Arrange
+ const string AgentName = "client-function-and-hosted-mcp-agent";
+ var chatClient = new TestHelpers.SimpleMockChatClient("Documentation found.");
+#pragma warning disable MEAI001
+ var hostedMcpTool = new HostedMcpServerTool(
+ serverName: "test_mcp",
+ serverAddress: "https://example.test/mcp")
+ {
+ ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
+ };
+#pragma warning restore MEAI001
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true
+ };
+#pragma warning restore MAAI001
+
+ this._httpClient = await this.CreateTestServerWithHostedToolAsync(
+ AgentName,
+ chatClient,
+ hostedMcpTool,
+ mapOptions);
+ using StringContent content = CreateClientFunctionRequest("mcp");
+
+ // Act
+ using HttpResponseMessage response = await this._httpClient.PostAsync(
+ new Uri($"/{AgentName}/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Contains(chatClient.LastChatOptions!.Tools!, tool => tool is HostedMcpServerTool);
+ Assert.Contains(chatClient.LastChatOptions.Tools!, tool =>
+ tool is AIFunctionDeclaration function && function.Name == "mcp");
+ }
+
+ [Fact]
+ public async Task CreateResponse_WithConflictingClientFunction_ForwardsBothToolsToChatClientAsync()
+ {
+ // Arrange
+ const string AgentName = "client-function-conflict-agent";
+ const string FunctionName = "get_weather";
+ int invocationCount = 0;
+ var chatClient = new TestHelpers.FunctionCallMockChatClient(
+ FunctionName,
+ """{"location":"Valencia"}""");
+ AIFunction hostedFunction = AIFunctionFactory.Create(GetWeather, FunctionName);
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true
+ };
+#pragma warning restore MAAI001
+
+ this._httpClient = await this.CreateTestServerWithHostedToolAsync(
+ AgentName,
+ chatClient,
+ hostedFunction,
+ mapOptions);
+ using StringContent content = CreateClientFunctionRequest(FunctionName);
+
+ // Act
+ using HttpResponseMessage response = await this._httpClient.PostAsync(
+ new Uri($"/{AgentName}/v1/responses", UriKind.Relative),
+ content);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(0, invocationCount);
+ Assert.Equal(2, chatClient.LastChatOptions!.Tools!.Count);
+ Assert.Contains(hostedFunction, chatClient.LastChatOptions.Tools);
+ AIFunctionDeclaration forwardedFunction =
+ Assert.IsAssignableFrom(Assert.Single(
+ chatClient.LastChatOptions.Tools, tool => tool is not AIFunction));
+ Assert.Equal("Client-provided function.", forwardedFunction.Description);
+ Assert.Contains(
+ "\"type\":\"function_call\"",
+ await response.Content.ReadAsStringAsync(),
+ StringComparison.Ordinal);
+
+ string GetWeather(string location)
+ {
+ invocationCount++;
+ return $"Sunny in {location}";
+ }
+ }
+
///
/// Verifies that responses with function calls stream correctly.
///
@@ -1409,7 +1648,11 @@ private async Task CreateTestServerWithCustomClientAndConversationsA
return testServer.CreateClient();
}
- private async Task CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
+ private async Task CreateTestServerWithCustomClientAsync(
+ string agentName,
+ string instructions,
+ IChatClient chatClient,
+ OpenAIResponsesMapOptions? mapOptions = null)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
@@ -1420,8 +1663,32 @@ private async Task CreateTestServerWithCustomClientAsync(string agen
this._app = builder.Build();
AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName);
- this._app.MapOpenAIResponses(agent);
+ this._app.MapOpenAIResponses(agent, responsesPath: null, mapOptions);
+
+ await this._app.StartAsync();
+
+ TestServer testServer = this._app.Services.GetRequiredService() as TestServer
+ ?? throw new InvalidOperationException("TestServer not found");
+
+ return testServer.CreateClient();
+ }
+
+ private async Task CreateTestServerWithHostedToolAsync(
+ string agentName,
+ IChatClient chatClient,
+ AITool tool,
+ OpenAIResponsesMapOptions? mapOptions = null)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ IHostedAgentBuilder agentBuilder = builder
+ .AddAIAgent(agentName, "You are a helpful assistant.", chatClient)
+ .WithAITool(tool);
+ builder.AddOpenAIResponses();
+ this._app = builder.Build();
+ this._app.MapOpenAIResponses(agentBuilder, path: null, mapOptions);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService() as TestServer
@@ -1430,6 +1697,32 @@ private async Task CreateTestServerWithCustomClientAsync(string agen
return testServer.CreateClient();
}
+ private static StringContent CreateClientFunctionRequest(string functionName) =>
+ new(
+ $$"""
+ {
+ "input": "What's the weather in Valencia?",
+ "tools": [
+ {
+ "type": "function",
+ "name": "{{functionName}}",
+ "description": "Client-provided function.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location": { "type": "string" }
+ },
+ "required": [ "location" ],
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ ]
+ }
+ """,
+ Encoding.UTF8,
+ "application/json");
+
private async Task CreateTestServerWithMultipleAgentsAsync(
params (string Name, string Instructions, string ResponseText)[] agents)
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesMapOptionsTests.cs
new file mode 100644
index 00000000000..ad82d9cebd5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesMapOptionsTests.cs
@@ -0,0 +1,166 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
+
+///
+/// Tests for default and custom Responses request mapping.
+///
+public sealed class OpenAIResponsesMapOptionsTests
+{
+ [Fact]
+ public void DefaultMapping_RejectsClientFunctions()
+ {
+ // Arrange
+ var mapOptions = new OpenAIResponsesMapOptions();
+ using JsonDocument body = CreateBody();
+
+ // Act & Assert
+#pragma warning disable MAAI001
+ Assert.False(mapOptions.DangerouslyAllowClientFunctionTools);
+#pragma warning restore MAAI001
+ Assert.Throws(() => OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions));
+ }
+
+ [Fact]
+ public void DefaultMapping_Enabled_ForwardsDuplicatesWithoutModifyingRequest()
+ {
+ // Arrange
+ using JsonDocument body = CreateBody();
+ var request = new OpenAIResponseRequestInfo
+ {
+ Tools = [body.RootElement.GetProperty("tools")[0], body.RootElement.GetProperty("tools")[1]]
+ };
+ var originalTools = request.Tools;
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = true };
+#pragma warning restore MAAI001
+
+ // Act
+ var result = Assert.IsType(mapOptions.RunOptionsFactory(request));
+
+ // Assert
+ Assert.Same(originalTools, request.Tools);
+ Assert.Equal(2, result.ChatOptions!.Tools!.Count);
+ Assert.All(result.ChatOptions.Tools, tool =>
+ {
+ Assert.Equal("get_weather", tool.Name);
+ Assert.IsAssignableFrom(tool);
+ Assert.False(tool is AIFunction);
+ });
+ Assert.Null(result.ChatOptions.ToolMode);
+ Assert.Null(result.ChatOptions.AllowMultipleToolCalls);
+ Assert.Null(result.ChatClientFactory);
+ }
+
+ [Theory]
+ [InlineData(false, null)]
+ [InlineData(true, null)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public void CustomMapping_ReceivesAllToolsAndReturnsUnchangedOptions(bool allowClientFunctions, bool? allowMultipleToolCalls)
+ {
+ // Arrange
+ using JsonDocument body = CreateBody();
+ AIFunction hostedFunction = AIFunctionFactory.Create(() => "hosted", "get_weather");
+ Func factory = client => client;
+ var configuredOptions = new ChatClientAgentRunOptions(new ChatOptions
+ {
+ Tools = [hostedFunction],
+ ToolMode = ChatToolMode.Auto,
+ AllowMultipleToolCalls = allowMultipleToolCalls
+ })
+ {
+ ChatClientFactory = factory
+ };
+ OpenAIResponseRequestInfo? capturedRequest = null;
+ int invocationCount = 0;
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = allowClientFunctions,
+ RunOptionsFactory = request =>
+ {
+ capturedRequest = request;
+ invocationCount++;
+ return configuredOptions;
+ }
+ };
+#pragma warning restore MAAI001
+
+ // Act
+ var run = OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions);
+
+ // Assert
+ Assert.Equal(1, invocationCount);
+ Assert.NotNull(capturedRequest);
+ Assert.Equal(2, capturedRequest.Tools!.Count);
+ Assert.Equal("function", capturedRequest.Tools[0].GetProperty("type").GetString());
+ Assert.Same(configuredOptions, run.Options);
+ Assert.Same(factory, configuredOptions.ChatClientFactory);
+ Assert.Equal(allowMultipleToolCalls, configuredOptions.ChatOptions!.AllowMultipleToolCalls);
+ Assert.Equal(ChatToolMode.Auto, configuredOptions.ChatOptions.ToolMode);
+ Assert.Same(hostedFunction, Assert.Single(configuredOptions.ChatOptions.Tools!));
+ }
+
+ [Fact]
+ public void CustomMapping_ReturningNull_DoesNotAddClientFunctions()
+ {
+ // Arrange
+ using JsonDocument body = CreateBody();
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true,
+ RunOptionsFactory = _ => null
+ };
+#pragma warning restore MAAI001
+
+ // Act
+ var run = OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions);
+
+ // Assert
+ Assert.Null(run.Options);
+ }
+
+ [Fact]
+ public void ExplicitRejectFactory_OverridesOptInRegardlessOfAssignmentOrder()
+ {
+ // Arrange
+ using JsonDocument body = CreateBody();
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ RunOptionsFactory = OpenAIResponsesMapOptions.RejectRequestSettings
+ };
+#pragma warning disable MAAI001
+ mapOptions.DangerouslyAllowClientFunctionTools = true;
+#pragma warning restore MAAI001
+
+ // Act & Assert
+ Assert.Throws(() => OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions));
+ }
+
+ [Fact]
+ public void RunOptionsFactory_Null_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mapOptions = new OpenAIResponsesMapOptions();
+
+ // Act & Assert
+ Assert.Throws(() => mapOptions.RunOptionsFactory = null!);
+ }
+
+ private static JsonDocument CreateBody() => JsonDocument.Parse(
+ """
+ {
+ "input": "hello",
+ "tools": [
+ {"type":"function","name":"get_weather","parameters":{"type":"object"}},
+ {"type":"function","name":"get_weather","parameters":{"type":"object"}}
+ ]
+ }
+ """);
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs
index 2ed5b735acb..14a3b9b1941 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs
@@ -27,6 +27,61 @@ public void ToAgentRunRequest_StringInput_ProducesUserMessage()
Assert.Null(request.Options);
}
+ [Fact]
+ public void ToAgentRunRequest_DangerousClientFunctionOptInWithoutTools_ReturnsNullOptions()
+ {
+ // Arrange
+ using var doc = JsonDocument.Parse("""{ "input": "Hello there" }""");
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true
+ };
+#pragma warning restore MAAI001
+
+ // Act
+ var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement, mapOptions);
+
+ // Assert
+ Assert.Null(request.Options);
+ }
+
+ [Fact]
+ public void ToAgentRunRequest_DangerousClientFunctionOptIn_ReturnsRunOptions()
+ {
+ // Arrange
+ using var doc = JsonDocument.Parse(
+ """
+ {
+ "input": "Hello there",
+ "tools": [
+ {
+ "type": "function",
+ "name": "client_function",
+ "parameters": { "type": "object" }
+ }
+ ]
+ }
+ """);
+#pragma warning disable MAAI001
+ var mapOptions = new OpenAIResponsesMapOptions
+ {
+ DangerouslyAllowClientFunctionTools = true
+ };
+#pragma warning restore MAAI001
+
+ // Act
+ OpenAIResponsesRunRequest request =
+ OpenAIResponses.ToAgentRunRequest(doc.RootElement, mapOptions);
+
+ // Assert
+ ChatClientAgentRunOptions runOptions =
+ Assert.IsType(request.Options);
+ AIFunctionDeclaration function =
+ Assert.IsAssignableFrom(Assert.Single(runOptions.ChatOptions!.Tools!));
+ Assert.Equal("client_function", function.Name);
+ }
+
[Fact]
public void GetSessionStoreId_PreviousResponseId_IsReturned()
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs
index 198e65629e4..85c68b284dc 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs
@@ -361,6 +361,8 @@ internal sealed class FunctionCallMockChatClient : IChatClient
private readonly string _functionName;
private readonly Dictionary _arguments;
+ public ChatOptions? LastChatOptions { get; private set; }
+
public FunctionCallMockChatClient(string functionName = "test_function", string arguments = "{\"param\":\"value\"}")
{
this._functionName = functionName;
@@ -374,6 +376,8 @@ public Task GetResponseAsync(
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
+ this.LastChatOptions = options;
+
ChatMessage message = new(ChatRole.Assistant, [
new FunctionCallContent("call_123", this._functionName)
{
@@ -399,6 +403,8 @@ public async IAsyncEnumerable GetStreamingResponseAsync(
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
+ this.LastChatOptions = options;
+
await Task.Delay(1, cancellationToken);
yield return new ChatResponseUpdate
@@ -427,6 +433,80 @@ public void Dispose()
}
}
+ ///
+ /// Mock implementation of IChatClient that requests a function and then returns a final response
+ /// after receiving the function result.
+ ///
+ internal sealed class FunctionToolExecutingMockChatClient : IChatClient
+ {
+ private readonly string _functionName;
+ private bool _functionRequested;
+
+ public FunctionToolExecutingMockChatClient(string functionName)
+ {
+ this._functionName = functionName;
+ }
+
+ public ChatOptions? FirstRequestOptions { get; private set; }
+
+ public string? FunctionResult { get; private set; }
+
+ public ChatClientMetadata Metadata { get; } = new(
+ "Test",
+ new Uri("https://test.example.com"),
+ "test-model");
+
+ public Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.Delay(1, cancellationToken);
+
+ if (!this._functionRequested)
+ {
+ this._functionRequested = true;
+ this.FirstRequestOptions = options;
+ yield return new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents =
+ [
+ new FunctionCallContent(
+ "call_1",
+ this._functionName,
+ new Dictionary { ["location"] = "Valencia" })
+ ]
+ };
+ yield break;
+ }
+
+ FunctionResultContent result = messages
+ .SelectMany(message => message.Contents)
+ .OfType()
+ .Single();
+ this.FunctionResult = result.Result?.ToString();
+ yield return new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = [new TextContent("The weather tool completed.")]
+ };
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) =>
+ serviceType.IsInstanceOfType(this) ? this : null;
+
+ public void Dispose()
+ {
+ }
+ }
+
///
/// Mock implementation of IChatClient that returns mixed content types.
///