diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs index f0c9286c68f..f7bfef9ce72 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Agents.AI.Hosting.OpenAI.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.AI; @@ -18,19 +19,40 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; internal static class AIAgentChatCompletionsProcessor { - public static async Task CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken) + public static async Task CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, OpenAIChatCompletionsMapOptions? mapOptions, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(agent); + var runOptionsFactory = (mapOptions ?? new OpenAIChatCompletionsMapOptions()).RunOptionsFactory; + + AgentRunOptions? runOptions; + try + { + // The hosting developer controls, via OpenAIChatCompletionsMapOptions.RunOptionsFactory, which (if any) + // request settings are mapped onto the agent run. By default no request setting is mapped. + runOptions = runOptionsFactory(request.ToRequestInfo()); + } + catch (NotSupportedException ex) + { + return Results.BadRequest(new ErrorResponse + { + Error = new ErrorDetails + { + Message = ex.Message, + Type = "invalid_request_error", + Code = "unsupported_parameter" + } + }); + } + var chatMessages = request.Messages.Select(i => i.ToChatMessage()); - var chatClientAgentRunOptions = request.BuildOptions(); if (request.Stream == true) { - return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions); + return new StreamingResponse(agent, request, chatMessages, runOptions); } - var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + var response = await agent.RunAsync(chatMessages, options: runOptions, cancellationToken: cancellationToken).ConfigureAwait(false); return Results.Ok(response.ToChatCompletion(request)); } @@ -38,7 +60,7 @@ private sealed class StreamingResponse( AIAgent agent, CreateChatCompletion request, IEnumerable chatMessages, - ChatClientAgentRunOptions? options) : IResult + AgentRunOptions? options) : IResult { public Task ExecuteAsync(HttpContext httpContext) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs index 3158d87848c..0878ad8bcb0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs @@ -12,35 +12,24 @@ internal static class ChatClientAgentRunOptionsConverter { private static readonly JsonElement s_emptyJson = JsonElement.Parse("{}"); - public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request) + /// + /// Projects the request-supplied generation and tool settings into a public + /// for use by a hosting-developer mapping callback. + /// + public static OpenAIChatCompletionRequestInfo ToRequestInfo(this CreateChatCompletion request) => new() { - ChatOptions chatOptions = new() - { - Temperature = request.Temperature, - MaxOutputTokens = request.MaxCompletionTokens, - FrequencyPenalty = request.FrequencyPenalty, - PresencePenalty = request.PresencePenalty, - Seed = request.Seed, - TopP = request.TopP, - StopSequences = request.Stop?.SequenceList ?? [], - ResponseFormat = request.ResponseFormat?.ToChatResponseFormat() - }; - - if (request.ToolChoice is not null) - { - chatOptions.ToolMode = request.ToolChoice.ToChatToolMode(); - } - - if (request.Tools?.Count > 0) - { - chatOptions.Tools = request.Tools.Select(x => x.ToAITool()).ToList(); - } - - return new() - { - ChatOptions = chatOptions - }; - } + Temperature = request.Temperature, + TopP = request.TopP, + MaxOutputTokens = request.MaxCompletionTokens, + FrequencyPenalty = request.FrequencyPenalty, + PresencePenalty = request.PresencePenalty, + Seed = request.Seed, + StopSequences = request.Stop?.SequenceList is { Count: > 0 } sequences ? [.. sequences] : null, + ResponseFormat = request.ResponseFormat?.ToChatResponseFormat(), + Model = request.Model, + ToolChoice = request.ToolChoice?.ToChatToolMode(), + Tools = request.Tools is { Count: > 0 } tools ? tools.Select(x => x.ToAITool()).ToList() : null, + }; private static ChatResponseFormat ToChatResponseFormat(this ResponseFormat responseFormat) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs index 92c817b1249..1e3f20f23fc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -5,6 +5,7 @@ using System.Threading; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; using Microsoft.AspNetCore.Mvc; @@ -29,10 +30,11 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpoint /// The to add the OpenAI ChatCompletions endpoints to. /// The builder for to map the OpenAI ChatCompletions endpoints for. /// Custom route path for the chat completions endpoint. - public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path) + /// Optional options controlling how incoming requests are mapped onto the agent run. + public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIChatCompletionsMapOptions? mapOptions = null) { var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); - return MapOpenAIChatCompletions(endpoints, agent, path); + return MapOpenAIChatCompletions(endpoints, agent, path, mapOptions); } /// @@ -49,10 +51,12 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpoint /// The to add the OpenAI ChatCompletions endpoints to. /// The instance to map the OpenAI ChatCompletions endpoints for. /// Custom route path for the chat completions endpoint. + /// Optional options controlling how incoming requests are mapped onto the agent run. public static IEndpointConventionBuilder MapOpenAIChatCompletions( this IEndpointRouteBuilder endpoints, AIAgent agent, - [StringSyntax("Route")] string? path) + [StringSyntax("Route")] string? path, + OpenAIChatCompletionsMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); @@ -64,7 +68,7 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions( var endpointAgentName = agent.Name ?? agent.Id; group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken) - => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false)) + => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false)) .WithName(endpointAgentName + "/CreateChatCompletion"); return group; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index ae96636f167..aeacdcaa4c3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -32,13 +32,14 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB /// The to add the OpenAI Responses endpoints to. /// The builder for to map the OpenAI Responses endpoints for. /// Custom route path for the OpenAI Responses endpoint. - public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path) + /// Optional options controlling how incoming requests are mapped onto the agent run. + public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agentBuilder); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); - return MapOpenAIResponses(endpoints, agent, path); + return MapOpenAIResponses(endpoints, agent, path, mapOptions); } /// @@ -55,10 +56,12 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB /// The to add the OpenAI Responses endpoints to. /// The instance to map the OpenAI Responses endpoints for. /// Custom route path for the responses endpoint. + /// Optional options controlling how incoming requests are mapped onto the agent run. public static IEndpointConventionBuilder MapOpenAIResponses( this IEndpointRouteBuilder endpoints, AIAgent agent, - [StringSyntax("Route")] string? responsesPath) + [StringSyntax("Route")] string? responsesPath, + OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); @@ -68,7 +71,7 @@ public static IEndpointConventionBuilder MapOpenAIResponses( responsesPath ??= $"/{agent.Name}/v1/responses"; // Create an executor for this agent - var executor = new AIAgentResponseExecutor(agent); + var executor = new AIAgentResponseExecutor(agent, mapOptions); var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions(); var conversationStorage = endpoints.ServiceProvider.GetService(); var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIChatCompletionRequestInfo.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIChatCompletionRequestInfo.cs new file mode 100644 index 00000000000..e345f184045 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIChatCompletionRequestInfo.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Exposes the request-supplied generation and tool settings of an OpenAI ChatCompletions +/// create chat completion request that a hosting developer may choose to map onto the +/// used to run the target . +/// +/// +/// +/// This type is passed to . By default no +/// request setting is mapped onto the agent, because an agent is typically self-contained and +/// allowing callers to override its configuration (for example which tools it may invoke) can cause +/// it to behave in ways its author did not intend. +/// +/// +/// Tool and response-format settings are surfaced using their Microsoft.Extensions.AI +/// equivalents so that a hosting developer can map them directly without re-parsing the wire format. +/// +/// +public sealed class OpenAIChatCompletionRequestInfo +{ + /// + /// Gets or sets the sampling temperature supplied on the request, if any. + /// + public float? Temperature { get; set; } + + /// + /// Gets or sets the nucleus sampling value (top_p) supplied on the request, if any. + /// + public float? TopP { get; set; } + + /// + /// Gets or sets the maximum number of completion tokens (max_completion_tokens) supplied on the request, if any. + /// + public int? MaxOutputTokens { get; set; } + + /// + /// Gets or sets the frequency penalty supplied on the request, if any. + /// + public float? FrequencyPenalty { get; set; } + + /// + /// Gets or sets the presence penalty supplied on the request, if any. + /// + public float? PresencePenalty { get; set; } + + /// + /// Gets or sets the deterministic sampling seed supplied on the request, if any. + /// + public long? Seed { get; set; } + + /// + /// Gets or sets the stop sequences supplied on the request, if any. + /// + public IReadOnlyList? StopSequences { get; set; } + + /// + /// Gets or sets the response format supplied on the request, if any. + /// + public ChatResponseFormat? ResponseFormat { get; set; } + + /// + /// Gets or sets the model identifier supplied on the request, if any. + /// + /// + /// This value is informational. It is not applied to local agent execution (the agent runs with + /// its own ), and the OpenAI ChatCompletions + /// wire format requires it on every request, so it is intentionally excluded from the default + /// rejection. + /// + public string? Model { get; set; } + + /// + /// Gets or sets the tool selection mode (tool_choice) supplied on the request, if any. + /// + public ChatToolMode? ToolChoice { get; set; } + + /// + /// Gets or sets the tools supplied on the request, if any. + /// + public IReadOnlyList? Tools { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIChatCompletionsMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIChatCompletionsMapOptions.cs new file mode 100644 index 00000000000..ba7f2f33d45 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIChatCompletionsMapOptions.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Options that control how an OpenAI ChatCompletions endpoint maps incoming requests onto the target +/// . +/// +public sealed class OpenAIChatCompletionsMapOptions +{ + /// + /// Gets or sets the callback used to produce the for a request from + /// the request-supplied generation and tool settings. + /// + /// + /// + /// By default this is set to , which throws when the request + /// carries any setting that would otherwise be mapped onto the agent (for example + /// temperature, tools or tool_choice). This prevents a caller from silently + /// overriding the configuration of a self-contained agent. + /// + /// + /// 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. + /// Returning runs the agent with its own configuration only. + /// + /// + public Func RunOptionsFactory + { + get; + set + { + field = Throw.IfNull(value); + } + } = RejectRequestSettings; + + /// + /// The default implementation. Throws a + /// when the request specifies any setting that would otherwise be mapped onto the agent, and otherwise + /// returns so that the agent runs with its own configuration only. + /// + /// The request-supplied settings. + /// Always when no unsupported setting is present. + /// + /// is intentionally not treated as an unsupported + /// setting: it is informational, is not applied to local execution, and is a required field of the + /// OpenAI ChatCompletions wire format (present on every request). + /// + /// One or more request settings are not supported. + public static AgentRunOptions? RejectRequestSettings(OpenAIChatCompletionRequestInfo request) + { + Throw.IfNull(request); + + List? unsupported = null; + void LocalAdd(string name) => (unsupported ??= []).Add(name); + + if (request.Temperature is not null) + { + LocalAdd("temperature"); + } + + if (request.TopP is not null) + { + LocalAdd("top_p"); + } + + if (request.MaxOutputTokens is not null) + { + LocalAdd("max_completion_tokens"); + } + + if (request.FrequencyPenalty is not null) + { + LocalAdd("frequency_penalty"); + } + + if (request.PresencePenalty is not null) + { + LocalAdd("presence_penalty"); + } + + if (request.Seed is not null) + { + LocalAdd("seed"); + } + + if (request.StopSequences is { Count: > 0 }) + { + LocalAdd("stop"); + } + + if (request.ResponseFormat is not null) + { + LocalAdd("response_format"); + } + + if (request.Tools is { Count: > 0 }) + { + LocalAdd("tools"); + } + + if (request.ToolChoice is not null) + { + LocalAdd("tool_choice"); + } + + if (unsupported is not null) + { + throw new NotSupportedException( + $"The following request setting(s) are not supported by this agent endpoint: {string.Join(", ", unsupported)}. " + + "Configure an OpenAIChatCompletionsMapOptions.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/OpenAIResponseRequestInfo.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs new file mode 100644 index 00000000000..0d5741984e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Exposes the request-supplied generation and tool settings of an OpenAI Responses +/// create response request that a hosting developer may choose to map onto the +/// used to run the target . +/// +/// +/// +/// This type is passed to . By default no +/// request setting is mapped onto the agent, because an agent is typically self-contained and +/// allowing callers to override its configuration (for example its instructions or which tools it +/// may invoke) can cause it to behave in ways its author did not intend. +/// +/// +/// Only the subset of request fields that are meaningful to map onto a local agent run are exposed. +/// The raw wire model is intentionally not surfaced. +/// +/// +public sealed class OpenAIResponseRequestInfo +{ + /// + /// Gets or sets the sampling temperature supplied on the request, if any. + /// + public double? Temperature { get; set; } + + /// + /// Gets or sets the nucleus sampling value (top_p) supplied on the request, if any. + /// + public double? TopP { get; set; } + + /// + /// Gets or sets the maximum number of output tokens supplied on the request, if any. + /// + public int? MaxOutputTokens { get; set; } + + /// + /// Gets or sets the instructions supplied on the request, if any. + /// + public string? Instructions { get; set; } + + /// + /// Gets or sets the model identifier supplied on the request, if any. + /// + /// + /// This value is informational. It is not applied to local agent execution (the agent runs with + /// its own ), so it is intentionally excluded + /// from the default rejection. + /// + public string? Model { get; set; } + + /// + /// Gets or sets the raw tools array supplied on the request, if any. + /// + /// + /// The OpenAI Responses wire format represents tools as JSON tool declarations rather than + /// executable functions, so they are surfaced here as the raw values. + /// + public IReadOnlyList? Tools { get; set; } + + /// + /// Gets or sets the tool selection mode (tool_choice) supplied on the request, if any. + /// + /// + /// The OpenAI Responses tool_choice value is mapped onto its + /// equivalent (none, auto, + /// required, or a specific function). Values that have no equivalent are surfaced as + /// . + /// + public ChatToolMode? ToolChoice { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs new file mode 100644 index 00000000000..63a550f2c1e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Options that control how an OpenAI Responses endpoint maps incoming requests onto the target +/// . +/// +public sealed class OpenAIResponsesMapOptions +{ + /// + /// Gets or sets the callback used to produce the for a request from + /// the request-supplied generation and tool settings. + /// + /// + /// + /// By default this is set to , 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. + /// + /// + /// 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. + /// Returning runs the agent with its own configuration only. + /// + /// + public Func RunOptionsFactory + { + get; + set + { + field = Throw.IfNull(value); + } + } = RejectRequestSettings; + + /// + /// The default implementation. Throws a + /// when the request specifies any setting that would otherwise be mapped onto the agent, and otherwise + /// returns so that the agent runs with its own configuration only. + /// + /// The request-supplied settings. + /// Always when no unsupported setting is present. + /// + /// is intentionally not treated as an unsupported + /// setting: it is informational and is not applied to local execution. + /// + /// One or more request settings are not supported. + public static AgentRunOptions? RejectRequestSettings(OpenAIResponseRequestInfo request) + { + ArgumentNullException.ThrowIfNull(request); + + List? unsupported = null; + void LocalAdd(string name) => (unsupported ??= []).Add(name); + + if (request.Temperature is not null) + { + LocalAdd("temperature"); + } + + if (request.TopP is not null) + { + LocalAdd("top_p"); + } + + if (request.MaxOutputTokens is not null) + { + LocalAdd("max_output_tokens"); + } + + if (request.Instructions is not null) + { + LocalAdd("instructions"); + } + + if (request.Tools is { Count: > 0 }) + { + LocalAdd("tools"); + } + + if (request.ToolChoice is not null) + { + LocalAdd("tool_choice"); + } + + if (unsupported is not null) + { + throw new NotSupportedException( + $"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 e2e07d00b73..959209a0aa4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -17,16 +17,38 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal sealed class AIAgentResponseExecutor : IResponseExecutor { private readonly AIAgent _agent; + private readonly Func _runOptionsFactory; - public AIAgentResponseExecutor(AIAgent agent) + public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(agent); this._agent = agent; + this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory; } public ValueTask ValidateRequestAsync( CreateResponse request, - CancellationToken cancellationToken = default) => ValueTask.FromResult(null); + CancellationToken cancellationToken = default) + => ValueTask.FromResult(this.ValidateRunOptions(request)); + + internal ResponseError? ValidateRunOptions(CreateResponse request) + { + try + { + // Invoke the factory 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()); + return null; + } + catch (NotSupportedException ex) + { + return new ResponseError + { + Code = "unsupported_parameter", + Message = ex.Message + }; + } + } public async IAsyncEnumerable ExecuteAsync( AgentInvocationContext context, @@ -34,23 +56,9 @@ public async IAsyncEnumerable ExecuteAsync( IReadOnlyList? conversationHistory = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Create options with properties from the request - var chatOptions = new ChatOptions - { - // Note: We intentionally do NOT set ConversationId on ChatOptions here. - // The conversation ID from the client request is used by the hosting layer - // to manage conversation storage, but should not be forwarded to the underlying - // IChatClient as it has its own concept of conversations (or none at all). - // --- - // ConversationId = request.Conversation?.Id, - - Temperature = (float?)request.Temperature, - TopP = (float?)request.TopP, - MaxOutputTokens = request.MaxOutputTokens, - Instructions = request.Instructions, - ModelId = request.Model, - }; - var options = new ChatClientAgentRunOptions(chatOptions); + // 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()); // 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 ad98e9e7551..bc51e74dc76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -21,21 +21,25 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; + private readonly Func _runOptionsFactory; /// /// Initializes a new instance of the class. /// /// The service provider used to resolve hosted agents. /// The logger instance. + /// Options controlling how incoming requests are mapped onto the agent run. public HostedAgentResponseExecutor( IServiceProvider serviceProvider, - ILogger logger) + ILogger logger, + OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(serviceProvider); ArgumentNullException.ThrowIfNull(logger); this._serviceProvider = serviceProvider; this._logger = logger; + this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory; } /// @@ -75,6 +79,21 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti }); } + // Surface unsupported request settings as a clean request error rather than an unhandled + // exception during execution. + try + { + _ = this._runOptionsFactory(request.ToRequestInfo()); + } + catch (NotSupportedException ex) + { + return ValueTask.FromResult(new ResponseError + { + Code = "unsupported_parameter", + Message = ex.Message + }); + } + return ValueTask.FromResult(null); } @@ -88,22 +107,9 @@ public async IAsyncEnumerable ExecuteAsync( string agentName = GetAgentName(request)!; AIAgent agent = this._serviceProvider.GetRequiredKeyedService(agentName); - var chatOptions = new ChatOptions - { - // Note: We intentionally do NOT set ConversationId on ChatOptions here. - // The conversation ID from the client request is used by the hosting layer - // to manage conversation storage, but should not be forwarded to the underlying - // IChatClient as it has its own concept of conversations (or none at all). - // --- - // ConversationId = request.Conversation?.Id, - - Temperature = (float?)request.Temperature, - TopP = (float?)request.TopP, - MaxOutputTokens = request.MaxOutputTokens, - Instructions = request.Instructions, - ModelId = request.Model, - }; - var options = new ChatClientAgentRunOptions(chatOptions); + // 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()); 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 new file mode 100644 index 00000000000..46b4532638b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; + +internal static class OpenAIResponseRequestInfoBuilder +{ + public static OpenAIResponseRequestInfo ToRequestInfo(this CreateResponse request) => new() + { + Temperature = request.Temperature, + TopP = request.TopP, + MaxOutputTokens = request.MaxOutputTokens, + Instructions = request.Instructions, + Model = request.Model, + Tools = request.Tools is { Count: > 0 } tools ? new List(tools) : null, + ToolChoice = request.ToolChoice?.ToChatToolMode(), + }; + + /// + /// Maps an OpenAI Responses tool_choice value onto its equivalent. + /// + /// + /// The Responses tool_choice is either a string (none, auto or required) + /// or an object identifying a specific tool (for example { "type": "function", "name": "..." }). + /// Values that have no equivalent are mapped to . + /// + private static ChatToolMode? ToChatToolMode(this JsonElement toolChoice) + { + switch (toolChoice.ValueKind) + { + case JsonValueKind.String: + return toolChoice.GetString() switch + { + "none" => ChatToolMode.None, + "auto" => ChatToolMode.Auto, + "required" => ChatToolMode.RequireAny, + _ => null + }; + + case JsonValueKind.Object: + // Only a function tool selection (for example { "type": "function", "name": "..." }) + // has a ChatToolMode equivalent. Other object shapes (e.g. hosted tool selections) are + // not mapped so that they are not mistaken for a specific function. + if (toolChoice.TryGetProperty("type", out JsonElement type) && type.ValueKind == JsonValueKind.String && + type.GetString() == "function" && + toolChoice.TryGetProperty("name", out JsonElement name) && name.ValueKind == JsonValueKind.String && + name.GetString() is { Length: > 0 } functionName) + { + return ChatToolMode.RequireSpecific(functionName); + } + + return null; + + default: + return null; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs index ccb86556240..7a20c901316 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs @@ -198,8 +198,8 @@ protected async Task CreateTestServerAsync(string agentName, string this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); - this._app.MapOpenAIResponses(agent); - this._app.MapOpenAIChatCompletions(agent); + this._app.MapOpenAIResponses(agent, responsesPath: null, PermissiveMapOptions.Responses()); + this._app.MapOpenAIChatCompletions(agent, path: null, PermissiveMapOptions.ChatCompletions()); await this._app.StartAsync(); @@ -229,8 +229,8 @@ protected async Task CreateTestServerAsync( this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); - this._app.MapOpenAIResponses(agent); - this._app.MapOpenAIChatCompletions(agent); + this._app.MapOpenAIResponses(agent, responsesPath: null, PermissiveMapOptions.Responses()); + this._app.MapOpenAIChatCompletions(agent, path: null, PermissiveMapOptions.ChatCompletions()); await this._app.StartAsync(); @@ -261,8 +261,8 @@ protected async Task CreateTestServerWithToolCallAsync( this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); - this._app.MapOpenAIResponses(agent); - this._app.MapOpenAIChatCompletions(agent); + this._app.MapOpenAIResponses(agent, responsesPath: null, PermissiveMapOptions.Responses()); + this._app.MapOpenAIChatCompletions(agent, path: null, PermissiveMapOptions.ChatCompletions()); await this._app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsConformanceTests.cs index 618d86f1c7e..7cad5ef6b92 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsConformanceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsConformanceTests.cs @@ -108,7 +108,7 @@ private async Task CreateTestServerAsync(string agentName, string in this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); this._app.MapOpenAIConversations(); - this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIResponses(agent, responsesPath: null, PermissiveMapOptions.Responses()); await this._app.StartAsync(); @@ -137,7 +137,7 @@ private async Task CreateTestServerWithStatefulMockAsync(string agen AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); this._app.MapOpenAIConversations(); - this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIResponses(agent, responsesPath: null, PermissiveMapOptions.Responses()); await this._app.StartAsync(); @@ -166,7 +166,7 @@ private async Task CreateTestServerWithToolCallAsync(string agentNam AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); this._app.MapOpenAIConversations(); - this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIResponses(agent, responsesPath: null, PermissiveMapOptions.Responses()); await this._app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs new file mode 100644 index 00000000000..b0ce53eaa08 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for and , +/// including the default behavior that rejects request-supplied settings so that callers cannot +/// override the configuration of a self-contained agent. +/// +public sealed class OpenAIMapOptionsTests +{ + [Fact] + public void Responses_DefaultRunOptionsFactory_IsRejectRequestSettings() + { + // Arrange & Act + var options = new OpenAIResponsesMapOptions(); + + // Assert + Assert.Equal(OpenAIResponsesMapOptions.RejectRequestSettings, options.RunOptionsFactory); + } + + [Fact] + public void ChatCompletions_DefaultRunOptionsFactory_IsRejectRequestSettings() + { + // Arrange & Act + var options = new OpenAIChatCompletionsMapOptions(); + + // Assert + Assert.Equal(OpenAIChatCompletionsMapOptions.RejectRequestSettings, options.RunOptionsFactory); + } + + [Fact] + public void Responses_RejectRequestSettings_ReturnsNull_WhenNoSettingsSpecified() + { + // Arrange + var request = new OpenAIResponseRequestInfo { Model = "gpt-4o" }; + + // Act + AgentRunOptions? result = OpenAIResponsesMapOptions.RejectRequestSettings(request); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("temperature")] + [InlineData("top_p")] + [InlineData("max_output_tokens")] + [InlineData("instructions")] + [InlineData("tools")] + [InlineData("tool_choice")] + public void Responses_RejectRequestSettings_Throws_WhenSettingSpecified(string setting) + { + // Arrange + var request = setting switch + { + "temperature" => new OpenAIResponseRequestInfo { Temperature = 0.5 }, + "top_p" => new OpenAIResponseRequestInfo { TopP = 0.5 }, + "max_output_tokens" => new OpenAIResponseRequestInfo { MaxOutputTokens = 100 }, + "instructions" => new OpenAIResponseRequestInfo { Instructions = "be brief" }, + "tools" => new OpenAIResponseRequestInfo { Tools = [ParseElement("{}")] }, + "tool_choice" => new OpenAIResponseRequestInfo { ToolChoice = ChatToolMode.None }, + _ => throw new ArgumentOutOfRangeException(nameof(setting)) + }; + + // Act & Assert + NotSupportedException ex = Assert.Throws(() => OpenAIResponsesMapOptions.RejectRequestSettings(request)); + Assert.Contains(setting, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ChatCompletions_RejectRequestSettings_ReturnsNull_WhenNoSettingsSpecified() + { + // Arrange + var request = new OpenAIChatCompletionRequestInfo { Model = "gpt-4o" }; + + // Act + AgentRunOptions? result = OpenAIChatCompletionsMapOptions.RejectRequestSettings(request); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("temperature")] + [InlineData("top_p")] + [InlineData("max_completion_tokens")] + [InlineData("frequency_penalty")] + [InlineData("presence_penalty")] + [InlineData("seed")] + [InlineData("stop")] + [InlineData("response_format")] + [InlineData("tools")] + [InlineData("tool_choice")] + public void ChatCompletions_RejectRequestSettings_Throws_WhenSettingSpecified(string setting) + { + // Arrange + var request = setting switch + { + "temperature" => new OpenAIChatCompletionRequestInfo { Temperature = 0.5f }, + "top_p" => new OpenAIChatCompletionRequestInfo { TopP = 0.5f }, + "max_completion_tokens" => new OpenAIChatCompletionRequestInfo { MaxOutputTokens = 100 }, + "frequency_penalty" => new OpenAIChatCompletionRequestInfo { FrequencyPenalty = 0.5f }, + "presence_penalty" => new OpenAIChatCompletionRequestInfo { PresencePenalty = 0.5f }, + "seed" => new OpenAIChatCompletionRequestInfo { Seed = 42 }, + "stop" => new OpenAIChatCompletionRequestInfo { StopSequences = ["stop"] }, + "response_format" => new OpenAIChatCompletionRequestInfo { ResponseFormat = ChatResponseFormat.Json }, + "tools" => new OpenAIChatCompletionRequestInfo { Tools = [AIFunctionFactory.Create(() => "x", "f")] }, + "tool_choice" => new OpenAIChatCompletionRequestInfo { ToolChoice = ChatToolMode.None }, + _ => throw new ArgumentOutOfRangeException(nameof(setting)) + }; + + // Act & Assert + NotSupportedException ex = Assert.Throws(() => OpenAIChatCompletionsMapOptions.RejectRequestSettings(request)); + Assert.Contains(setting, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Responses_DefaultEndpoint_RejectsRequestWithSettingsAsync() + { + // Arrange + using var app = await CreateResponsesServerAsync("reject-agent", mapOptions: null); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-agent/v1/responses", UriKind.Relative), + new StringContent("""{"input":"hello","temperature":0.5}""", Encoding.UTF8, "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(); + Assert.Contains("temperature", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Responses_ConfiguredEndpoint_HonorsRequestSettingsAsync() + { + // Arrange + using var app = await CreateResponsesServerAsync("map-agent", PermissiveMapOptions.Responses()); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/map-agent/v1/responses", UriKind.Relative), + new StringContent("""{"input":"hello","temperature":0.5}""", Encoding.UTF8, "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ChatCompletions_DefaultEndpoint_RejectsRequestWithSettingsAsync() + { + // Arrange + using var app = await CreateChatCompletionsServerAsync("reject-agent", mapOptions: null); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-agent/v1/chat/completions", UriKind.Relative), + new StringContent("""{"model":"myModel","messages":[{"role":"user","content":"hello"}],"temperature":0.5}""", Encoding.UTF8, "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(); + Assert.Contains("temperature", body, StringComparison.Ordinal); + Assert.Contains("invalid_request_error", body, StringComparison.Ordinal); + } + + [Fact] + public async Task ChatCompletions_ConfiguredEndpoint_HonorsRequestSettingsAsync() + { + // Arrange + using var app = await CreateChatCompletionsServerAsync("map-agent", PermissiveMapOptions.ChatCompletions()); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/map-agent/v1/chat/completions", UriKind.Relative), + new StringContent("""{"model":"myModel","messages":[{"role":"user","content":"hello"}],"temperature":0.5}""", Encoding.UTF8, "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static async Task CreateResponsesServerAsync(string agentName, OpenAIResponsesMapOptions? mapOptions) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient("Hello there."); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent(agentName, "You are a helpful assistant.", chatClientServiceKey: "chat-client"); + builder.AddOpenAIResponses(); + + WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService(agentName); + app.MapOpenAIResponses(agent, responsesPath: null, mapOptions); + + await app.StartAsync(); + return app; + } + + private static HttpClient GetClient(WebApplication app) + { + TestServer testServer = app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + return testServer.CreateClient(); + } + + private static async Task CreateChatCompletionsServerAsync(string agentName, OpenAIChatCompletionsMapOptions? mapOptions) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient("Hello there."); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent(agentName, "You are a helpful assistant.", chatClientServiceKey: "chat-client"); + builder.AddOpenAIChatCompletions(); + + WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService(agentName); + app.MapOpenAIChatCompletions(agent, path: null, mapOptions); + + await app.StartAsync(); + return app; + } + + 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/OpenAIResponseRequestInfoBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs new file mode 100644 index 00000000000..bef3325dd7d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for , in particular the mapping of the OpenAI +/// Responses tool_choice wire value onto its equivalent. +/// +public sealed class OpenAIResponseRequestInfoBuilderTests +{ + [Fact] + public void ToRequestInfo_MapsToolChoiceNone_ToChatToolModeNone() + { + // Arrange + CreateResponse request = CreateRequestWithToolChoice("\"none\""); + + // Act + OpenAIResponseRequestInfo info = request.ToRequestInfo(); + + // Assert + Assert.Equal(ChatToolMode.None, info.ToolChoice); + } + + [Fact] + public void ToRequestInfo_MapsToolChoiceAuto_ToChatToolModeAuto() + { + // Arrange + CreateResponse request = CreateRequestWithToolChoice("\"auto\""); + + // Act + OpenAIResponseRequestInfo info = request.ToRequestInfo(); + + // Assert + Assert.Equal(ChatToolMode.Auto, info.ToolChoice); + } + + [Fact] + public void ToRequestInfo_MapsToolChoiceRequired_ToRequireAny() + { + // Arrange + CreateResponse request = CreateRequestWithToolChoice("\"required\""); + + // Act + OpenAIResponseRequestInfo info = request.ToRequestInfo(); + + // Assert + Assert.Equal(ChatToolMode.RequireAny, info.ToolChoice); + } + + [Fact] + public void ToRequestInfo_MapsSpecificFunctionToolChoice_ToRequireSpecific() + { + // Arrange + CreateResponse request = CreateRequestWithToolChoice("""{"type":"function","name":"get_weather"}"""); + + // Act + OpenAIResponseRequestInfo info = request.ToRequestInfo(); + + // Assert + RequiredChatToolMode required = Assert.IsType(info.ToolChoice); + Assert.Equal("get_weather", required.RequiredFunctionName); + } + + [Fact] + public void ToRequestInfo_MapsUnrecognizedToolChoice_ToNull() + { + // Arrange + CreateResponse request = CreateRequestWithToolChoice("\"something_else\""); + + // Act + OpenAIResponseRequestInfo info = request.ToRequestInfo(); + + // Assert + Assert.Null(info.ToolChoice); + } + + [Fact] + public void ToRequestInfo_NoToolChoice_MapsToNull() + { + // Arrange + CreateResponse request = new() { Input = "hello" }; + + // Act + OpenAIResponseRequestInfo info = request.ToRequestInfo(); + + // Assert + Assert.Null(info.ToolChoice); + } + + private static CreateResponse CreateRequestWithToolChoice(string toolChoiceJson) + { + using JsonDocument document = JsonDocument.Parse(toolChoiceJson); + return new() + { + Input = "hello", + ToolChoice = document.RootElement.Clone(), + }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs new file mode 100644 index 00000000000..80a9af2895c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Provides permissive and +/// that map the request-supplied generation and tool settings onto the agent run. +/// +/// +/// The production default rejects request-supplied settings so that callers cannot override a +/// self-contained agent. These helpers opt in to the legacy behavior for conformance tests that +/// exercise the wire format end-to-end. +/// +internal static class PermissiveMapOptions +{ + public static OpenAIResponsesMapOptions Responses() => new() + { + RunOptionsFactory = static request => + { + var chatOptions = new ChatOptions + { + Temperature = (float?)request.Temperature, + TopP = (float?)request.TopP, + MaxOutputTokens = request.MaxOutputTokens, + Instructions = request.Instructions, + ModelId = request.Model, + ToolMode = request.ToolChoice, + }; + + return new ChatClientAgentRunOptions(chatOptions); + } + }; + + public static OpenAIChatCompletionsMapOptions ChatCompletions() => new() + { + RunOptionsFactory = static request => + { + var chatOptions = new ChatOptions + { + Temperature = request.Temperature, + TopP = request.TopP, + MaxOutputTokens = request.MaxOutputTokens, + FrequencyPenalty = request.FrequencyPenalty, + PresencePenalty = request.PresencePenalty, + Seed = request.Seed, + StopSequences = request.StopSequences is { Count: > 0 } stop ? [.. stop] : null, + ResponseFormat = request.ResponseFormat, + ToolMode = request.ToolChoice, + Tools = request.Tools is { Count: > 0 } tools ? tools.ToList() : null, + ModelId = request.Model, + }; + + return new ChatClientAgentRunOptions(chatOptions); + } + }; +}