.NET: Enable access to hosted AIAgents via OpenAI Responses (#947)

* init

* wip

* wip wip wip

* wip wip

* open up API

* enable for multiple agents

* more wip

* make frontend respond.

* wip

* not sure if proper setup

* define type

* cleanup

* frontend streaming wip

* use System.Net.ServerSentEvents

* usings

* reformat via ichatclient

* merge main renaming + refactor

* fix main merge + fix sample (a2a change)

* fix sample

* some rebase (not working yet)

* make it at least build somehow

* make non-stream work without internal types

* Input without custom models

* implement streaming

* test frontend

* enable alerts and fix

* build fixes & rereview

* Update dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI.Responses/Microsoft.Agents.AI.Hosting.OpenAI.Responses.csproj

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI.Responses/Utils/ResponseItemJsonConverter.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix agent discovery

* rename

* rename project into Microsoft.Agents.AI.Hosting.OpenAI (no responses in name)

* PR address comments x1

* address PR comments x2

* correctly instantiate OpenAIResponse

* address PR comments x3

* reconfigure JSON serialization & handle AOT warnings

* fix build

* proper ref

* check update differently

* correct check

* exclude dotnet format diagnostics for IL2026 and IL3050

* space  :)

* re-review

* add comments

* remove unnecessary using

* always take last openai response item

* set responseItem Id explicitly

* add agent.name validation for uri

* cleanup

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
This commit is contained in:
Korolev Dmitry
2025-10-07 14:39:08 +02:00
committed by GitHub
Unverified
parent 3fd5768b34
commit d10b44d6bb
23 changed files with 994 additions and 174 deletions
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Provides extension methods for mapping OpenAI Responses capabilities to an <see cref="AIAgent"/>.
/// </summary>
public static class EndpointRouteBuilderExtensions
{
/// <summary>
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
/// <param name="agentName">The name of the AI agent service registered in the dependency injection container. This name is used to resolve the <see cref="AIAgent"/> instance from the keyed services.</param>
/// <param name="responsesPath">Custom route path for the responses endpoint.</param>
/// <param name="conversationsPath">Custom route path for the conversations endpoint.</param>
public static void MapOpenAIResponses(
this IEndpointRouteBuilder endpoints,
string agentName,
[StringSyntax("Route")] string? responsesPath = null,
[StringSyntax("Route")] string? conversationsPath = null)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentName);
if (responsesPath is null || conversationsPath is null)
{
ValidateAgentName(agentName);
}
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
responsesPath ??= $"/{agentName}/v1/responses";
var responsesRouteGroup = endpoints.MapGroup(responsesPath);
MapResponses(responsesRouteGroup, agent);
// Will be included once we obtain the API to operate with thread (conversation).
// conversationsPath ??= $"/{agentName}/v1/conversations";
// var conversationsRouteGroup = endpoints.MapGroup(conversationsPath);
// MapConversations(conversationsRouteGroup, agent, loggerFactory);
}
private static void MapResponses(IEndpointRouteBuilder routeGroup, AIAgent agent)
{
var endpointAgentName = agent.DisplayName;
var responsesProcessor = new AIAgentResponsesProcessor(agent);
routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) =>
{
var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false);
var responseOptions = new ResponseCreationOptions();
var responseOptionsJsonModel = responseOptions as IJsonModel<ResponseCreationOptions>;
Debug.Assert(responseOptionsJsonModel is not null);
responseOptions = responseOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json);
if (responseOptions is null)
{
return Results.BadRequest("Invalid request payload.");
}
return await responsesProcessor.CreateModelResponseAsync(responseOptions, cancellationToken).ConfigureAwait(false);
}).WithName(endpointAgentName + "/CreateResponse");
}
#pragma warning disable IDE0051 // Remove unused private members
private static void MapConversations(IEndpointRouteBuilder routeGroup, AIAgent agent)
#pragma warning restore IDE0051 // Remove unused private members
{
var endpointAgentName = agent.DisplayName;
var conversationsProcessor = new AIAgentConversationsProcessor(agent);
routeGroup.MapGet("/{conversation_id}", (string conversationId, CancellationToken cancellationToken)
=> conversationsProcessor.GetConversationAsync(conversationId, cancellationToken)
).WithName(endpointAgentName + "/RetrieveConversation");
}
private static void ValidateAgentName([NotNull] string agentName)
{
var escaped = Uri.EscapeDataString(agentName);
if (!string.Equals(escaped, agentName, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"Agent name '{agentName}' contains characters invalid for URL routes.", nameof(agentName));
}
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006;IDE0130;NU1504;OPENAI001</NoWarn>
<RootNamespace>Microsoft.Agents.AI.Hosting.OpenAI</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.1.25451.107" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
internal sealed class AIAgentConversationsProcessor
{
#pragma warning disable IDE0052 // Remove unread private members
private readonly AIAgent _aiAgent;
#pragma warning restore IDE0052 // Remove unread private members
public AIAgentConversationsProcessor(AIAgent aiAgent)
{
this._aiAgent = aiAgent ?? throw new ArgumentNullException(nameof(aiAgent));
}
public async Task<IResult> GetConversationAsync(string conversationId, CancellationToken cancellationToken)
{
// TODO come back to it later
throw new NotImplementedException();
}
}
@@ -0,0 +1,184 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Model;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
/// <summary>
/// OpenAI Responses processor associated with a specific <see cref="AIAgent"/>.
/// </summary>
internal sealed class AIAgentResponsesProcessor
{
private readonly AIAgent _agent;
public AIAgentResponsesProcessor(AIAgent agent)
{
this._agent = agent ?? throw new ArgumentNullException(nameof(agent));
}
public async Task<IResult> CreateModelResponseAsync(ResponseCreationOptions responseCreationOptions, CancellationToken cancellationToken)
{
var options = new OpenAIResponsesRunOptions();
AgentThread? agentThread = null; // not supported to resolve from conversationId
var inputItems = responseCreationOptions.GetInput();
var chatMessages = inputItems.AsChatMessages();
if (responseCreationOptions.GetStream())
{
return new OpenAIStreamingResponsesResult(this._agent, chatMessages);
}
var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, options, cancellationToken).ConfigureAwait(false);
return new OpenAIResponseResult(agentResponse);
}
private sealed class OpenAIResponseResult(AgentRunResponse agentResponse) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
{
// note: OpenAI SDK types provide their own serialization implementation
// so we cant simply return IResult wrap for the typed-object.
// instead writing to the response body can be done.
var cancellationToken = httpContext.RequestAborted;
var response = httpContext.Response;
var chatResponse = agentResponse.AsChatResponse();
var openAIResponse = chatResponse.AsOpenAIResponse();
var openAIResponseJsonModel = openAIResponse as IJsonModel<OpenAIResponse>;
Debug.Assert(openAIResponseJsonModel is not null);
var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false });
openAIResponseJsonModel.Write(writer, ModelReaderWriterOptions.Json);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}
private sealed class OpenAIStreamingResponsesResult(AIAgent agent, IEnumerable<ChatMessage> chatMessages) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
var cancellationToken = httpContext.RequestAborted;
var response = httpContext.Response;
// Set SSE headers
response.Headers.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache,no-store";
response.Headers.Connection = "keep-alive";
response.Headers.ContentEncoding = "identity";
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
return SseFormatter.WriteAsync(
source: this.GetStreamingResponsesAsync(cancellationToken),
destination: response.Body,
itemFormatter: (sseItem, bufferWriter) =>
{
var jsonTypeInfo = OpenAIResponsesJsonUtilities.DefaultOptions.GetTypeInfo(sseItem.Data.GetType());
var json = JsonSerializer.SerializeToUtf8Bytes(sseItem.Data, jsonTypeInfo);
bufferWriter.Write(json);
},
cancellationToken);
}
private async IAsyncEnumerable<SseItem<StreamingResponseEventBase>> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var sequenceNumber = 1;
var outputIndex = 1;
AgentThread? agentThread = null;
ResponseItem? lastResponseItem = null;
OpenAIResponse? lastOpenAIResponse = null;
await foreach (var update in agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken).ConfigureAwait(false))
{
if (string.IsNullOrEmpty(update.ResponseId)
&& string.IsNullOrEmpty(update.MessageId)
&& update.Contents is not { Count: > 0 })
{
continue;
}
if (sequenceNumber == 1)
{
lastOpenAIResponse = update.AsChatResponse().AsOpenAIResponse();
var responseCreated = new StreamingCreatedResponse(sequenceNumber++)
{
Response = lastOpenAIResponse
};
yield return new(responseCreated, responseCreated.Type);
}
if (update.Contents is not { Count: > 0 })
{
continue;
}
// to help convert the AIContent into OpenAI ResponseItem we pack it into the known "chatMessage"
// and use existing convertion extension method
var chatMessage = new ChatMessage(ChatRole.Assistant, update.Contents)
{
MessageId = update.MessageId,
CreatedAt = update.CreatedAt,
RawRepresentation = update.RawRepresentation
};
foreach (var openAIResponseItem in MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseItems([chatMessage]))
{
if (chatMessage.MessageId is not null)
{
openAIResponseItem.SetId(chatMessage.MessageId);
}
lastResponseItem = openAIResponseItem;
var responseOutputItemAdded = new StreamingOutputItemAddedResponse(sequenceNumber++)
{
OutputIndex = outputIndex++,
Item = openAIResponseItem
};
yield return new(responseOutputItemAdded, responseOutputItemAdded.Type);
}
}
if (lastResponseItem is not null)
{
// we were streaming "response.output_item.added" before
// so we should complete it now via "response.output_item.done"
var responseOutputDoneAdded = new StreamingOutputItemDoneResponse(sequenceNumber++)
{
OutputIndex = outputIndex++,
Item = lastResponseItem
};
yield return new(responseOutputDoneAdded, responseOutputDoneAdded.Type);
}
if (lastOpenAIResponse is not null)
{
// complete the whole streaming with the full response model
var responseCompleted = new StreamingCompletedResponse(sequenceNumber++)
{
Response = lastOpenAIResponse
};
yield return new(responseCompleted, responseCompleted.Type);
}
}
}
}
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Model;
/// <summary>
/// Abstract base class for all streaming response events in the OpenAI Responses API.
/// Provides common properties shared across all streaming event types.
/// </summary>
[JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(StreamingOutputItemAddedResponse), StreamingOutputItemAddedResponse.EventType)]
[JsonDerivedType(typeof(StreamingOutputItemDoneResponse), StreamingOutputItemDoneResponse.EventType)]
[JsonDerivedType(typeof(StreamingCreatedResponse), StreamingCreatedResponse.EventType)]
[JsonDerivedType(typeof(StreamingCompletedResponse), StreamingCompletedResponse.EventType)]
internal abstract class StreamingResponseEventBase
{
/// <summary>
/// Gets or sets the type identifier for the streaming response event.
/// This property is used to discriminate between different event types during serialization.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; }
/// <summary>
/// Gets or sets the sequence number of this event in the streaming response.
/// Events are numbered sequentially starting from 1 to maintain ordering.
/// </summary>
[JsonPropertyName("sequence_number")]
public int SequenceNumber { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="StreamingResponseEventBase"/> class.
/// </summary>
/// <param name="type">The type identifier for this streaming response event.</param>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
[JsonConstructor]
public StreamingResponseEventBase(string type, int sequenceNumber)
{
this.Type = type;
this.SequenceNumber = sequenceNumber;
}
}
/// <summary>
/// Represents a streaming response event indicating that a new output item has been added to the response.
/// This event is sent when the AI agent produces a new piece of content during streaming.
/// </summary>
internal sealed class StreamingOutputItemAddedResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for output item added events.
/// </summary>
public const string EventType = "response.output_item.added";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingOutputItemAddedResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingOutputItemAddedResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// Gets or sets the index of the output in the response where this item was added.
/// Multiple outputs can exist in a single response, and this identifies which one.
/// </summary>
[JsonPropertyName("output_index")]
public int OutputIndex { get; set; }
/// <summary>
/// Gets or sets the response item that was added to the output.
/// This contains the actual content or data produced by the AI agent.
/// </summary>
[JsonPropertyName("item")]
public ResponseItem? Item { get; set; }
}
/// <summary>
/// Represents a streaming response event indicating that an output item has been completed.
/// This event is sent when the AI agent finishes producing a particular piece of content.
/// </summary>
internal sealed class StreamingOutputItemDoneResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for output item done events.
/// </summary>
public const string EventType = "response.output_item.done";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingOutputItemDoneResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingOutputItemDoneResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// Gets or sets the index of the output in the response where this item was completed.
/// This corresponds to the same output index from the associated <see cref="StreamingOutputItemAddedResponse"/>.
/// </summary>
[JsonPropertyName("output_index")]
public int OutputIndex { get; set; }
/// <summary>
/// Gets or sets the completed response item.
/// This contains the final version of the content produced by the AI agent.
/// </summary>
[JsonPropertyName("item")]
public ResponseItem? Item { get; set; }
}
/// <summary>
/// Represents a streaming response event indicating that a new response has been created and streaming has begun.
/// This is typically the first event sent in a streaming response sequence.
/// </summary>
internal sealed class StreamingCreatedResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for response created events.
/// </summary>
public const string EventType = "response.created";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingCreatedResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingCreatedResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// Gets or sets the OpenAI response object that was created.
/// This contains metadata about the response including ID, creation timestamp, and other properties.
/// </summary>
[JsonPropertyName("response")]
public required OpenAIResponse Response { get; set; }
}
/// <summary>
/// Represents a streaming response event indicating that the response has been completed.
/// This is typically the last event sent in a streaming response sequence.
/// </summary>
internal sealed class StreamingCompletedResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for response completed events.
/// </summary>
public const string EventType = "response.completed";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingCompletedResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingCompletedResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// Gets or sets the completed OpenAI response object.
/// This contains the final state of the response including all generated content and metadata.
/// </summary>
[JsonPropertyName("response")]
public required OpenAIResponse Response { get; set; }
}
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
internal sealed class OpenAIResponsesRunOptions : AgentRunOptions
{
public bool Background { get; init; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils;
internal static class AgentRunResponseUpdateExtensions
{
/// <summary>
/// Converts an <see cref="AgentRunResponseUpdate"/> instance to a <see cref="ChatResponse"/>.
/// </summary>
/// <param name="response">The <see cref="AgentRunResponse"/> to convert. Cannot be null.</param>
/// <param name="role">The role of agent run response contents. By default is <see cref="ChatRole.Assistant"/>.</param>
/// <returns>A <see cref="ChatResponse"/> populated with values from <paramref name="response"/>.</returns>
public static ChatResponse AsChatResponse(this AgentRunResponseUpdate response, ChatRole? role = null) => new()
{
CreatedAt = response.CreatedAt,
ResponseId = response.ResponseId,
RawRepresentation = response.RawRepresentation,
AdditionalProperties = response.AdditionalProperties,
Messages = [new ChatMessage(response.Role ?? role ?? ChatRole.Assistant, response.Contents)]
};
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils;
internal sealed class OpenAIResponseJsonConverter : JsonConverter<OpenAIResponse>
{
public override OpenAIResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var item = OpenAIResponsesModelFactory.OpenAIResponse();
var jsonModel = item as IJsonModel<OpenAIResponse>;
Debug.Assert(jsonModel is not null, "OpenAIResponse should implement IJsonModel<OpenAIResponse>");
return jsonModel.Create(ref reader, ModelReaderWriterOptions.Json);
}
public override void Write(Utf8JsonWriter writer, OpenAIResponse value, JsonSerializerOptions options)
{
var jsonModel = value as IJsonModel<OpenAIResponse>;
Debug.Assert(jsonModel is not null, "OpenAIResponse should implement IJsonModel<OpenAIResponse>");
jsonModel.Write(writer, ModelReaderWriterOptions.Json);
}
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Model;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils;
internal static partial class OpenAIResponsesJsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agents-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
JsonSerializerOptions options = new(JsonContext.Default.Options);
options.Converters.Add(new ResponseItemJsonConverter());
options.Converters.Add(new OpenAIResponseJsonConverter());
options.MakeReadOnly();
return options;
}
[JsonSerializable(typeof(StreamingResponseEventBase))]
[ExcludeFromCodeCoverage]
private sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils;
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")]
internal static class ResponseCreationOptionsExtensions
{
private static readonly Func<ResponseCreationOptions, bool?> _getStreamNullable;
private static readonly Func<ResponseCreationOptions, IList<ResponseItem>> _getInput;
static ResponseCreationOptionsExtensions()
{
// OpenAI SDK does not have a simple way to get the input as a c# object.
// However, it does parse most of the interesting fields into internal properties of `ResponseCreationOptions` object.
// --- Stream (internal bool? Stream { get; set; }) ---
const string streamPropName = "Stream";
var streamProp = typeof(ResponseCreationOptions).GetProperty(streamPropName, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMemberException(typeof(ResponseCreationOptions).FullName!, streamPropName);
var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{streamPropName} getter not found.");
_getStreamNullable = streamGetter.CreateDelegate<Func<ResponseCreationOptions, bool?>>();
// --- Input (internal IList<ResponseItem> Input { get; set; }) ---
const string inputPropName = "Input";
var inputProp = typeof(ResponseCreationOptions).GetProperty(inputPropName, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMemberException(typeof(ResponseCreationOptions).FullName!, inputPropName);
var inputGetter = inputProp.GetGetMethod(nonPublic: true)
?? throw new MissingMethodException($"{inputPropName} getter not found.");
_getInput = inputGetter.CreateDelegate<Func<ResponseCreationOptions, IList<ResponseItem>>>();
}
public static bool GetStream(this ResponseCreationOptions options)
{
Throw.IfNull(options);
return _getStreamNullable(options) ?? false;
}
public static IList<ResponseItem> GetInput(this ResponseCreationOptions options)
{
Throw.IfNull(options);
return _getInput(options);
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils;
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")]
internal static class ResponseItemExtensions
{
private static readonly Action<ResponseItem, string> _setId;
static ResponseItemExtensions()
{
// OpenAI SDK ResponseItem has an internal setter for Id property.
// We need to access it via reflection to set the Id when creating response items.
// --- Id (public string Id { get; internal set; }) ---
const string idPropName = "Id";
var idProp = typeof(ResponseItem).GetProperty(idPropName, BindingFlags.Instance | BindingFlags.Public)
?? throw new MissingMemberException(typeof(ResponseItem).FullName!, idPropName);
var idSetter = idProp.GetSetMethod(nonPublic: true) ?? throw new MissingMethodException($"{idPropName} setter not found.");
_setId = idSetter.CreateDelegate<Action<ResponseItem, string>>();
}
/// <summary>
/// Sets the Id property on a ResponseItem using reflection to access the internal setter.
/// </summary>
/// <param name="responseItem">The ResponseItem to set the Id on.</param>
/// <param name="id">The Id value to set.</param>
public static void SetId(this ResponseItem responseItem, string id)
{
Throw.IfNull(responseItem);
_setId(responseItem, id);
}
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils;
internal sealed class ResponseItemJsonConverter : JsonConverter<ResponseItem?>
{
public override ResponseItem? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var item = ResponseItem.CreateUserMessageItem(""); // no other way to instantiate it.
var jsonModel = item as IJsonModel<ResponseItem>;
Debug.Assert(jsonModel is not null, "ResponseItem should implement IJsonModel<ResponseItem>");
return jsonModel.Create(ref reader, ModelReaderWriterOptions.Json);
}
public override void Write(Utf8JsonWriter writer, ResponseItem? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
var jsonmodel = value as IJsonModel<ResponseItem>;
Debug.Assert(jsonmodel is not null, "ResponseItem should implement IJsonModel<ResponseItem>");
jsonmodel.Write(writer, ModelReaderWriterOptions.Json);
}
}
@@ -1,10 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Sent to an <see cref="AIAgent"/>-based executor to request
/// a response to accumulated <see cref="Extensions.AI.ChatMessage"/>.
/// a response to accumulated <see cref="ChatMessage"/>.
/// </summary>
/// <param name="emitEvents">Whether to raise AgentRunEvents for this executor.</param>
public class TurnToken(bool? emitEvents = null)
@@ -601,7 +601,7 @@ public sealed partial class ChatClientAgent : AIAgent
{
throw new InvalidOperationException(
$"""
The {nameof(chatOptions.ConversationId)} provided via {nameof(Extensions.AI.ChatOptions)} is different to the id of the provided {nameof(AgentThread)}.
The {nameof(chatOptions.ConversationId)} provided via {nameof(this.ChatOptions)} is different to the id of the provided {nameof(AgentThread)}.
Only one id can be used for a run.
""");
}