diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 692970636e..7fb6459906 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -53,6 +53,7 @@
+
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 86eb57b7c3..d9e51b6aa2 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs
@@ -68,7 +68,7 @@ internal static class AIAgentChatCompletionsProcessor
{
// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
DateTimeOffset? createdAt = null;
- var chunkId = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13);
+ var chunkId = IdGenerator.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13);
await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs
index 9674b261a3..f50aa44d4d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs
@@ -20,7 +20,7 @@ internal static class AgentRunResponseExtensions
return new ChatCompletion
{
- Id = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13),
+ Id = IdGenerator.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13),
Choices = choices,
Created = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
Model = request.Model,
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs
index f646010ac4..1a0a37cdc0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs
@@ -8,6 +8,14 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
internal static class MessageContentPartConverter
{
+ private static string AudioFormatToMediaType(string format) =>
+ format.Equals("mp3", StringComparison.OrdinalIgnoreCase) ? "audio/mpeg" :
+ format.Equals("wav", StringComparison.OrdinalIgnoreCase) ? "audio/wav" :
+ format.Equals("opus", StringComparison.OrdinalIgnoreCase) ? "audio/opus" :
+ format.Equals("aac", StringComparison.OrdinalIgnoreCase) ? "audio/aac" :
+ format.Equals("flac", StringComparison.OrdinalIgnoreCase) ? "audio/flac" :
+ format.Equals("pcm16", StringComparison.OrdinalIgnoreCase) ? "audio/pcm" :
+ "audio/*";
public static AIContent? ToAIContent(MessageContentPart part)
{
return part switch
@@ -23,16 +31,7 @@ internal static class MessageContentPartConverter
// audio
AudioContentPart audioPart =>
- new DataContent(audioPart.InputAudio.Data, audioPart.InputAudio.Format.ToUpperInvariant() switch
- {
- "MP3" => "audio/mpeg",
- "WAV" => "audio/wav",
- "OPUS" => "audio/opus",
- "AAC" => "audio/aac",
- "FLAC" => "audio/flac",
- "PCM16" => "audio/pcm",
- _ => "audio/*"
- }),
+ new DataContent(audioPart.InputAudio.Data, AudioFormatToMediaType(audioPart.InputAudio.Format)),
// file
FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileId)
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/ConversationsHttpHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/ConversationsHttpHandler.cs
new file mode 100644
index 0000000000..474bec9699
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/ConversationsHttpHandler.cs
@@ -0,0 +1,342 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
+
+///
+/// Handles route requests for OpenAI Conversations API endpoints.
+///
+internal sealed class ConversationsHttpHandler
+{
+ private readonly IConversationStorage _storage;
+ private readonly IAgentConversationIndex? _conversationIndex;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The conversation storage service.
+ /// Optional conversation index service.
+ public ConversationsHttpHandler(IConversationStorage storage, IAgentConversationIndex? conversationIndex)
+ {
+ this._storage = storage ?? throw new ArgumentNullException(nameof(storage));
+ this._conversationIndex = conversationIndex;
+ }
+
+ ///
+ /// Lists conversations by agent ID.
+ ///
+ public async Task ListConversationsByAgentAsync(
+ [FromQuery] string? agent_id,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(agent_id))
+ {
+ return Results.BadRequest(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = "agent_id query parameter is required.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ // Return empty list if conversation index is not registered
+ if (this._conversationIndex == null)
+ {
+ return Results.Ok(new ListResponse
+ {
+ Data = [],
+ HasMore = false
+ });
+ }
+
+ var conversationIdsResponse = await this._conversationIndex.GetConversationIdsAsync(agent_id, cancellationToken).ConfigureAwait(false);
+
+ // Fetch full conversation objects
+ var conversations = new List();
+ foreach (var conversationId in conversationIdsResponse.Data)
+ {
+ var conversation = await this._storage.GetConversationAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ if (conversation is not null)
+ {
+ conversations.Add(conversation);
+ }
+ }
+
+ return Results.Ok(new ListResponse
+ {
+ Data = conversations,
+ HasMore = false
+ });
+ }
+
+ ///
+ /// Creates a new conversation.
+ ///
+ public async Task CreateConversationAsync(
+ [FromBody] CreateConversationRequest request,
+ CancellationToken cancellationToken)
+ {
+ Dictionary metadata = request.Metadata ?? [];
+ var idGenerator = new IdGenerator(responseId: null, conversationId: null);
+ var conversation = new Conversation
+ {
+ Id = idGenerator.ConversationId,
+ CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ Metadata = metadata
+ };
+
+ var created = await this._storage.CreateConversationAsync(conversation, cancellationToken).ConfigureAwait(false);
+
+ // Add initial items if provided
+ if (request.Items is { Count: > 0 })
+ {
+ List itemsToAdd = [.. request.Items.Select(itemParam => itemParam.ToItemResource(idGenerator))];
+ await this._storage.AddItemsAsync(created.Id, itemsToAdd, cancellationToken).ConfigureAwait(false);
+ }
+
+ // Add to conversation index if available and agent_id is provided in metadata
+ if (this._conversationIndex != null && created.Metadata.TryGetValue("agent_id", out var agentId) && !string.IsNullOrEmpty(agentId))
+ {
+ await this._conversationIndex.AddConversationAsync(agentId, created.Id, cancellationToken).ConfigureAwait(false);
+ }
+
+ return Results.Ok(created);
+ }
+
+ ///
+ /// Retrieves a conversation by ID.
+ ///
+ public async Task GetConversationAsync(
+ string conversationId,
+ CancellationToken cancellationToken)
+ {
+ var conversation = await this._storage.GetConversationAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ return conversation is not null
+ ? Results.Ok(conversation)
+ : Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Conversation '{conversationId}' not found.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ ///
+ /// Updates a conversation's metadata.
+ ///
+ public async Task UpdateConversationAsync(
+ string conversationId,
+ [FromBody] UpdateConversationRequest request,
+ CancellationToken cancellationToken)
+ {
+ var existing = await this._storage.GetConversationAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ if (existing is null)
+ {
+ return Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Conversation '{conversationId}' not found.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ var updated = existing with
+ {
+ Metadata = request.Metadata
+ };
+
+ var result = await this._storage.UpdateConversationAsync(updated, cancellationToken).ConfigureAwait(false);
+ return Results.Ok(result);
+ }
+
+ ///
+ /// Deletes a conversation and all its messages.
+ ///
+ public async Task DeleteConversationAsync(
+ string conversationId,
+ CancellationToken cancellationToken)
+ {
+ // Get conversation first to retrieve agent_id for index removal
+ var conversation = await this._storage.GetConversationAsync(conversationId, cancellationToken).ConfigureAwait(false);
+
+ var deleted = await this._storage.DeleteConversationAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ if (!deleted)
+ {
+ return Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Conversation '{conversationId}' not found.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ // Remove from conversation index if available and agent_id was present in metadata
+ if (this._conversationIndex != null && conversation?.Metadata.TryGetValue("agent_id", out var agentId) == true && !string.IsNullOrEmpty(agentId))
+ {
+ await this._conversationIndex.RemoveConversationAsync(agentId, conversationId, cancellationToken).ConfigureAwait(false);
+ }
+
+ return Results.Ok(new DeleteResponse
+ {
+ Id = conversationId,
+ Object = "conversation.deleted",
+ Deleted = true
+ });
+ }
+
+ ///
+ /// Adds items to a conversation.
+ ///
+ public async Task CreateItemsAsync(
+ string conversationId,
+ [FromBody] CreateItemsRequest request,
+ [FromQuery] string[]? include,
+ CancellationToken cancellationToken)
+ {
+ var conversation = await this._storage.GetConversationAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ if (conversation is null)
+ {
+ return Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Conversation '{conversationId}' not found.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ var idGenerator = new IdGenerator(responseId: null, conversationId: conversationId);
+ List createdItems = [.. request.Items.Select(itemParam => itemParam.ToItemResource(idGenerator))];
+ await this._storage.AddItemsAsync(conversationId, createdItems, cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(new ListResponse
+ {
+ Data = createdItems,
+ FirstId = createdItems.Count > 0 ? createdItems[0].Id : null,
+ LastId = createdItems.Count > 0 ? createdItems[^1].Id : null,
+ HasMore = false
+ });
+ }
+
+ ///
+ /// Lists items in a conversation.
+ ///
+ public async Task ListItemsAsync(
+ string conversationId,
+ [FromQuery] int? limit,
+ [FromQuery] string? order,
+ [FromQuery] string? after,
+ [FromQuery] string[]? include,
+ CancellationToken cancellationToken)
+ {
+ // Validate limit parameter
+ if (limit is < 1)
+ {
+ return Results.BadRequest(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = "Invalid value for 'limit': must be a positive integer.",
+ Type = "invalid_request_error",
+ Code = "invalid_value"
+ }
+ });
+ }
+
+ var conversation = await this._storage.GetConversationAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ if (conversation is null)
+ {
+ return Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Conversation '{conversationId}' not found.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ var result = await this._storage.ListItemsAsync(conversationId, limit, ParseOrder(order), after, cancellationToken).ConfigureAwait(false);
+ return Results.Ok(result);
+ }
+
+ ///
+ /// Retrieves a specific item.
+ ///
+ public async Task GetItemAsync(
+ string conversationId,
+ string itemId,
+ [FromQuery] string[]? include,
+ CancellationToken cancellationToken)
+ {
+ var item = await this._storage.GetItemAsync(conversationId, itemId, cancellationToken).ConfigureAwait(false);
+ return item is not null
+ ? Results.Ok(item)
+ : Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Item '{itemId}' not found in conversation '{conversationId}'.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ ///
+ /// Deletes a specific item.
+ ///
+ public async Task DeleteItemAsync(
+ string conversationId,
+ string itemId,
+ CancellationToken cancellationToken)
+ {
+ var deleted = await this._storage.DeleteItemAsync(conversationId, itemId, cancellationToken).ConfigureAwait(false);
+ if (!deleted)
+ {
+ return Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Item '{itemId}' not found in conversation '{conversationId}'.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ return Results.Ok(new DeleteResponse
+ {
+ Id = itemId,
+ Object = "conversation.item.deleted",
+ Deleted = true
+ });
+ }
+
+ private static SortOrder? ParseOrder(string? order)
+ {
+ if (order is null)
+ {
+ return null;
+ }
+
+ return string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase) ? SortOrder.Ascending : SortOrder.Descending;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/IAgentConversationIndex.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/IAgentConversationIndex.cs
new file mode 100644
index 0000000000..a1e89d1676
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/IAgentConversationIndex.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
+
+///
+/// Optional service for indexing conversations by agent ID.
+/// This is a non-standard extension to the OpenAI Conversations API.
+///
+internal interface IAgentConversationIndex
+{
+ ///
+ /// Adds a conversation to the index for the specified agent.
+ ///
+ /// The agent identifier.
+ /// The conversation identifier.
+ /// Cancellation token.
+ /// A task that represents the asynchronous operation.
+ Task AddConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes a conversation from the index for the specified agent.
+ ///
+ /// The agent identifier.
+ /// The conversation identifier.
+ /// Cancellation token.
+ /// A task that represents the asynchronous operation.
+ Task RemoveConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all conversation IDs for the specified agent.
+ ///
+ /// The agent identifier.
+ /// Cancellation token.
+ /// A list response containing conversation IDs associated with the agent.
+ Task> GetConversationIdsAsync(string agentId, CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/IConversationStorage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/IConversationStorage.cs
new file mode 100644
index 0000000000..a289699edb
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/IConversationStorage.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
+
+///
+/// Storage abstraction for conversations and messages.
+/// This interface provides operations specifically designed for conversation management,
+/// going beyond simple key-value storage to support conversation-specific queries and operations.
+///
+internal interface IConversationStorage
+{
+ ///
+ /// Creates a new conversation.
+ ///
+ /// The conversation to create.
+ /// Cancellation token.
+ /// The created conversation.
+ Task CreateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a conversation by ID.
+ ///
+ /// The conversation ID.
+ /// Cancellation token.
+ /// The conversation if found, null otherwise.
+ Task GetConversationAsync(string conversationId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates an existing conversation.
+ ///
+ /// The conversation with updated values.
+ /// Cancellation token.
+ /// The updated conversation if found, null otherwise.
+ Task UpdateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a conversation and all its messages.
+ ///
+ /// The conversation ID.
+ /// Cancellation token.
+ /// True if deleted, false if not found.
+ Task DeleteConversationAsync(string conversationId, CancellationToken cancellationToken = default);
+
+ // Item operations
+
+ ///
+ /// Adds multiple items to a conversation atomically.
+ /// Items are ItemResource objects from the Responses API.
+ ///
+ /// The conversation ID to add the items to.
+ /// The items to add.
+ /// Cancellation token.
+ /// A task that completes when all items have been added.
+ Task AddItemsAsync(string conversationId, IEnumerable items, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves an item by ID.
+ ///
+ /// The conversation ID.
+ /// The item ID.
+ /// Cancellation token.
+ /// The item if found, null otherwise.
+ Task GetItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists items in a conversation with pagination support.
+ ///
+ /// The conversation ID.
+ /// Maximum number of items to return (default: 20, max: 100).
+ /// Sort order (default: Descending).
+ /// Cursor for pagination - return items after this ID.
+ /// Cancellation token.
+ /// A list response with items and pagination info.
+ Task> ListItemsAsync(
+ string conversationId,
+ int? limit = null,
+ SortOrder? order = null,
+ string? after = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a specific item from a conversation.
+ ///
+ /// The conversation ID.
+ /// The item ID.
+ /// Cancellation token.
+ /// True if deleted, false if not found.
+ Task DeleteItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryAgentConversationIndex.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryAgentConversationIndex.cs
new file mode 100644
index 0000000000..a2e2007dbe
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryAgentConversationIndex.cs
@@ -0,0 +1,121 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
+
+///
+/// In-memory implementation of IAgentConversationIndex for development and testing.
+/// This is a non-standard extension to the OpenAI Conversations API.
+///
+internal sealed class InMemoryAgentConversationIndex : IAgentConversationIndex, IDisposable
+{
+ private readonly MemoryCache _cache;
+ private readonly InMemoryStorageOptions _options;
+
+ private sealed class ConversationSet
+ {
+ private readonly HashSet _conversations = [];
+ private readonly object _lock = new();
+
+ public void Add(string conversationId)
+ {
+ lock (this._lock)
+ {
+ this._conversations.Add(conversationId);
+ }
+ }
+
+ public bool Remove(string conversationId)
+ {
+ lock (this._lock)
+ {
+ return this._conversations.Remove(conversationId);
+ }
+ }
+
+ public string[] GetAll()
+ {
+ lock (this._lock)
+ {
+ return [.. this._conversations];
+ }
+ }
+ }
+
+ public InMemoryAgentConversationIndex()
+ : this(new InMemoryStorageOptions())
+ {
+ }
+
+ public InMemoryAgentConversationIndex(InMemoryStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ this._options = options;
+ this._cache = new MemoryCache(options.ToMemoryCacheOptions());
+ }
+
+ private async Task GetOrCreateConversationSetAsync(string agentId, CancellationToken cancellationToken)
+ {
+ var conversationSet = await this._cache.GetOrCreateAtomicAsync(
+ agentId,
+ entry =>
+ {
+ entry.SetOptions(this._options.ToMemoryCacheEntryOptions());
+ return new ConversationSet();
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ return conversationSet!;
+ }
+
+ ///
+ public async Task AddConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(agentId);
+ ArgumentException.ThrowIfNullOrEmpty(conversationId);
+
+ ConversationSet conversationSet = await this.GetOrCreateConversationSetAsync(agentId, cancellationToken).ConfigureAwait(false);
+ conversationSet.Add(conversationId);
+ }
+
+ ///
+ public async Task RemoveConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(agentId);
+ ArgumentException.ThrowIfNullOrEmpty(conversationId);
+
+ if (this._cache.TryGetValue(agentId, out ConversationSet? conversationSet) && conversationSet is not null)
+ {
+ conversationSet.Remove(conversationId);
+ }
+ }
+
+ ///
+ public async Task> GetConversationIdsAsync(string agentId, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(agentId);
+
+ string[] conversations = (this._cache.TryGetValue(agentId, out ConversationSet? conversationSet) && conversationSet is not null)
+ ? conversationSet.GetAll()
+ : [];
+
+ return new ListResponse
+ {
+ Data = [.. conversations],
+ HasMore = false
+ };
+ }
+
+ public void Dispose()
+ {
+ // The MemoryCache will call the post-eviction callbacks when disposed,
+ // which will dispose all ConversationSet instances
+ this._cache.Dispose();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryConversationStorage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryConversationStorage.cs
new file mode 100644
index 0000000000..11b9dd9f0a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryConversationStorage.cs
@@ -0,0 +1,346 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
+
+///
+/// In-memory implementation of conversation storage for testing and development.
+/// This implementation is thread-safe but data is not persisted across application restarts.
+///
+internal sealed class InMemoryConversationStorage : IConversationStorage, IDisposable
+{
+ private const int DefaultListItemLimit = 20;
+
+ private readonly MemoryCache _cache;
+ private readonly InMemoryStorageOptions _options;
+
+ public InMemoryConversationStorage()
+ : this(new InMemoryStorageOptions())
+ {
+ }
+
+ public InMemoryConversationStorage(InMemoryStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ this._options = options;
+ this._cache = new MemoryCache(options.ToMemoryCacheOptions());
+ }
+
+ ///
+ public Task CreateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default)
+ {
+ // Check if conversation already exists
+ if (this._cache.TryGetValue(conversation.Id, out ConversationState? _))
+ {
+ throw new InvalidOperationException($"Conversation with ID '{conversation.Id}' already exists.");
+ }
+
+ var state = new ConversationState(conversation);
+ var entryOptions = this._options.ToMemoryCacheEntryOptions();
+ this._cache.Set(conversation.Id, state, entryOptions);
+ return Task.FromResult(conversation);
+ }
+
+ ///
+ public Task GetConversationAsync(string conversationId, CancellationToken cancellationToken = default)
+ {
+ if (this._cache.TryGetValue(conversationId, out ConversationState? state) && state is not null)
+ {
+ return Task.FromResult(state.Conversation);
+ }
+
+ return Task.FromResult(null);
+ }
+
+ ///
+ public Task UpdateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default)
+ {
+ if (this._cache.TryGetValue(conversation.Id, out ConversationState? state) && state is not null)
+ {
+ state.UpdateConversation(conversation);
+ // Touch the cache entry to reset expiration
+ var entryOptions = this._options.ToMemoryCacheEntryOptions();
+ this._cache.Set(conversation.Id, state, entryOptions);
+ return Task.FromResult(conversation);
+ }
+
+ return Task.FromResult(null);
+ }
+
+ ///
+ public Task DeleteConversationAsync(string conversationId, CancellationToken cancellationToken = default)
+ {
+ if (this._cache.TryGetValue(conversationId, out _))
+ {
+ this._cache.Remove(conversationId);
+ return Task.FromResult(true);
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task AddItemsAsync(string conversationId, IEnumerable items, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(conversationId, nameof(conversationId));
+ ArgumentNullException.ThrowIfNull(items);
+
+ if (!this._cache.TryGetValue(conversationId, out ConversationState? state) || state is null)
+ {
+ throw new InvalidOperationException($"Conversation '{conversationId}' not found.");
+ }
+
+ foreach (ItemResource item in items)
+ {
+ state.AddItem(item);
+ }
+
+ // Touch the cache entry to reset expiration
+ var entryOptions = this._options.ToMemoryCacheEntryOptions();
+ this._cache.Set(conversationId, state, entryOptions);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task GetItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default)
+ {
+ if (this._cache.TryGetValue(conversationId, out ConversationState? state) && state is not null)
+ {
+ return Task.FromResult(state.GetItem(itemId));
+ }
+
+ return Task.FromResult(null);
+ }
+
+ ///
+ public Task> ListItemsAsync(
+ string conversationId,
+ int? limit = null,
+ SortOrder? order = null,
+ string? after = null,
+ CancellationToken cancellationToken = default)
+ {
+ int effectiveLimit = Math.Clamp(limit ?? DefaultListItemLimit, 1, 100);
+ SortOrder effectiveOrder = order ?? SortOrder.Descending;
+
+ if (!this._cache.TryGetValue(conversationId, out ConversationState? state) || state is null)
+ {
+ throw new InvalidOperationException($"Conversation '{conversationId}' not found.");
+ }
+
+ var allItems = state.GetAllItems();
+
+ // For descending order, reverse the list
+ if (effectiveOrder == SortOrder.Descending)
+ {
+ allItems.Reverse();
+ }
+
+ var filtered = allItems.AsEnumerable();
+
+ if (!string.IsNullOrEmpty(after))
+ {
+ var afterIndex = allItems.FindIndex(m => m.Id == after);
+ if (afterIndex >= 0)
+ {
+ filtered = allItems.Skip(afterIndex + 1);
+ }
+ }
+
+ List result;
+ bool hasMore;
+
+ if (filtered.TryGetNonEnumeratedCount(out int count))
+ {
+ hasMore = count > effectiveLimit;
+ result = filtered.Take(effectiveLimit).ToList();
+ }
+ else
+ {
+ result = filtered.Take(effectiveLimit + 1).ToList();
+ hasMore = result.Count > effectiveLimit;
+ if (hasMore)
+ {
+ result = result.Take(effectiveLimit).ToList();
+ }
+ }
+
+ return Task.FromResult(new ListResponse
+ {
+ Data = result,
+ FirstId = result.FirstOrDefault()?.Id,
+ LastId = result.LastOrDefault()?.Id,
+ HasMore = hasMore
+ });
+ }
+
+ ///
+ public Task DeleteItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default)
+ {
+ if (this._cache.TryGetValue(conversationId, out ConversationState? state) && state is not null)
+ {
+ var removed = state.RemoveItem(itemId);
+ if (removed)
+ {
+ // Touch the cache entry to reset expiration
+ var entryOptions = this._options.ToMemoryCacheEntryOptions();
+ this._cache.Set(conversationId, state, entryOptions);
+ }
+
+ return Task.FromResult(removed);
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ /// Encapsulates per-conversation state including items storage and synchronization.
+ ///
+ private sealed class ConversationState
+ {
+#if NET9_0_OR_GREATER
+ private readonly OrderedDictionary _items = [];
+ private readonly object _lock = new();
+ private Conversation _conversation;
+
+ public ConversationState(Conversation conversation)
+ {
+ this._conversation = conversation;
+ }
+
+ public Conversation Conversation
+ {
+ get
+ {
+ lock (this._lock)
+ {
+ return this._conversation;
+ }
+ }
+ }
+
+ public void UpdateConversation(Conversation conversation)
+ {
+ lock (this._lock)
+ {
+ this._conversation = conversation;
+ }
+ }
+
+ public void AddItem(ItemResource item)
+ {
+ lock (this._lock)
+ {
+ if (!this._items.TryAdd(item.Id, item))
+ {
+ throw new InvalidOperationException($"Item with ID '{item.Id}' already exists.");
+ }
+ }
+ }
+
+ public ItemResource? GetItem(string itemId)
+ {
+ lock (this._lock)
+ {
+ this._items.TryGetValue(itemId, out var item);
+ return item;
+ }
+ }
+
+ public List GetAllItems()
+ {
+ lock (this._lock)
+ {
+ return this._items.Values.ToList();
+ }
+ }
+
+ public bool RemoveItem(string itemId)
+ {
+ lock (this._lock)
+ {
+ return this._items.Remove(itemId);
+ }
+ }
+#else
+ private readonly List _items = [];
+ private readonly object _lock = new();
+ private Conversation _conversation;
+
+ public ConversationState(Conversation conversation)
+ {
+ this._conversation = conversation;
+ }
+
+ public Conversation Conversation
+ {
+ get
+ {
+ lock (this._lock)
+ {
+ return this._conversation;
+ }
+ }
+ }
+
+ public void UpdateConversation(Conversation conversation)
+ {
+ lock (this._lock)
+ {
+ this._conversation = conversation;
+ }
+ }
+
+ public void AddItem(ItemResource item)
+ {
+ lock (this._lock)
+ {
+ if (this._items.Exists(i => i.Id == item.Id))
+ {
+ throw new InvalidOperationException($"Item with ID '{item.Id}' already exists.");
+ }
+
+ this._items.Add(item);
+ }
+ }
+
+ public ItemResource? GetItem(string itemId)
+ {
+ lock (this._lock)
+ {
+ return this._items.Find(i => i.Id == itemId);
+ }
+ }
+
+ public List GetAllItems()
+ {
+ lock (this._lock)
+ {
+ return this._items.ToList();
+ }
+ }
+
+ public bool RemoveItem(string itemId)
+ {
+ lock (this._lock)
+ {
+ return this._items.RemoveAll(i => i.Id == itemId) > 0;
+ }
+ }
+#endif
+ }
+
+ public void Dispose()
+ {
+ this._cache.Dispose();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/AddMessageRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/AddMessageRequest.cs
new file mode 100644
index 0000000000..29eac6f1da
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/AddMessageRequest.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+
+///
+/// Request to create items in a conversation.
+///
+internal sealed class CreateItemsRequest
+{
+ ///
+ /// The items to add to the conversation. You may add up to 20 items at a time.
+ /// Items should be ItemParam objects (messages without IDs, function call outputs, etc.).
+ /// The server will assign IDs when creating the items.
+ ///
+ [JsonPropertyName("items")]
+ public required List Items { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/Conversation.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/Conversation.cs
new file mode 100644
index 0000000000..ad37894804
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/Conversation.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+
+///
+/// Represents a conversation in the system.
+///
+internal sealed record Conversation
+{
+ ///
+ /// The unique identifier for the conversation.
+ ///
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ ///
+ /// The object type, always "conversation".
+ ///
+ [JsonPropertyName("object")]
+ [SuppressMessage("Naming", "CA1720:Identifiers should not match keywords", Justification = "Matches OpenAI API specification")]
+ public string Object => "conversation";
+
+ ///
+ /// The Unix timestamp (in seconds) for when the conversation was created.
+ ///
+ [JsonPropertyName("created_at")]
+ public required long CreatedAt { get; init; }
+
+ ///
+ /// Set of 16 key-value pairs that can be attached to a conversation.
+ ///
+ [JsonPropertyName("metadata")]
+ public Dictionary Metadata { get; init; } = [];
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/CreateConversationRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/CreateConversationRequest.cs
new file mode 100644
index 0000000000..1c90946b5f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/CreateConversationRequest.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+
+///
+/// Request to create a new conversation.
+///
+internal sealed class CreateConversationRequest
+{
+ ///
+ /// Initial items to include in the conversation context. You may add up to 20 items at a time.
+ /// Items should be ItemParam objects (messages without IDs, as the server will generate them).
+ ///
+ [JsonPropertyName("items")]
+ public List? Items { get; init; }
+
+ ///
+ /// Set of 16 key-value pairs that can be attached to a conversation.
+ ///
+ [JsonPropertyName("metadata")]
+ public Dictionary? Metadata { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/UpdateConversationRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/UpdateConversationRequest.cs
new file mode 100644
index 0000000000..bc0cc50512
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/Models/UpdateConversationRequest.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+
+///
+/// Request to update an existing conversation.
+///
+internal sealed class UpdateConversationRequest
+{
+ ///
+ /// Set of 16 key-value pairs that can be attached to a conversation.
+ ///
+ [JsonPropertyName("metadata")]
+ public required Dictionary Metadata { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/SortOrderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/SortOrderExtensions.cs
new file mode 100644
index 0000000000..4f64a99e8f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/SortOrderExtensions.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
+
+///
+/// Extension methods for .
+///
+internal static class SortOrderExtensions
+{
+ ///
+ /// Converts a to its string representation.
+ ///
+ /// The sort order.
+ /// The string representation ("asc" or "desc").
+ public static string ToOrderString(this SortOrder order)
+ {
+ return order == SortOrder.Ascending ? "asc" : "desc";
+ }
+
+ ///
+ /// Checks if the sort order is ascending.
+ ///
+ /// The sort order.
+ /// True if ascending, false otherwise.
+ public static bool IsAscending(this SortOrder order)
+ {
+ return order == SortOrder.Ascending;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Conversations.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Conversations.cs
new file mode 100644
index 0000000000..0c4af2cfb5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Conversations.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Builder;
+
+///
+/// Provides extension methods for mapping OpenAI Conversations API to an .
+///
+public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions
+{
+ ///
+ /// Maps OpenAI Conversations API endpoints to the specified .
+ ///
+ /// The to add the OpenAI Conversations endpoints to.
+ public static IEndpointConventionBuilder MapOpenAIConversations(this IEndpointRouteBuilder endpoints)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+
+ var storage = endpoints.ServiceProvider.GetService()
+ ?? throw new InvalidOperationException("IConversationStorage is not registered. Call AddOpenAIConversations() in your service configuration.");
+ var conversationIndex = endpoints.ServiceProvider.GetService();
+ var handlers = new ConversationsHttpHandler(storage, conversationIndex);
+
+ var group = endpoints.MapGroup("/v1/conversations")
+ .WithTags("Conversations");
+
+ // Conversation endpoints
+ // Non-standard extension: List conversations by agent ID
+ group.MapGet("", handlers.ListConversationsByAgentAsync)
+ .WithName("ListConversationsByAgent")
+ .WithSummary("List conversations for a specific agent (non-standard extension)");
+
+ group.MapPost("", handlers.CreateConversationAsync)
+ .WithName("CreateConversation")
+ .WithSummary("Create a new conversation");
+
+ group.MapGet("{conversationId}", handlers.GetConversationAsync)
+ .WithName("GetConversation")
+ .WithSummary("Retrieve a conversation by ID");
+
+ group.MapPost("{conversationId}", handlers.UpdateConversationAsync)
+ .WithName("UpdateConversation")
+ .WithSummary("Update a conversation's metadata or title");
+
+ group.MapDelete("{conversationId}", handlers.DeleteConversationAsync)
+ .WithName("DeleteConversation")
+ .WithSummary("Delete a conversation and all its messages");
+
+ // Item endpoints
+ group.MapPost("{conversationId}/items", handlers.CreateItemsAsync)
+ .WithName("CreateItems")
+ .WithSummary("Add items to a conversation");
+
+ group.MapGet("{conversationId}/items", handlers.ListItemsAsync)
+ .WithName("ListItems")
+ .WithSummary("List items in a conversation");
+
+ group.MapGet("{conversationId}/items/{itemId}", handlers.GetItemAsync)
+ .WithName("GetItem")
+ .WithSummary("Retrieve a specific item");
+
+ group.MapDelete("{conversationId}/items/{itemId}", handlers.DeleteItemAsync)
+ .WithName("DeleteItem")
+ .WithSummary("Delete a specific item");
+
+ 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 0b89340167..f4159011cf 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs
@@ -2,12 +2,11 @@
using System;
using System.Diagnostics.CodeAnalysis;
-using System.Threading;
using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.OpenAI;
+using Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
-using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
@@ -43,11 +42,43 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
ValidateAgentName(agent.Name);
responsesPath ??= $"/{agent.Name}/v1/responses";
+
+ // Create an executor for this agent
+ var executor = new AIAgentResponseExecutor(agent);
+ var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions();
+ var conversationStorage = endpoints.ServiceProvider.GetService();
+ var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage);
+
+ var handlers = new ResponsesHttpHandler(responsesService);
+
var group = endpoints.MapGroup(responsesPath);
var endpointAgentName = agent.DisplayName;
- group.MapPost("/", async ([FromBody] CreateResponse createResponse, CancellationToken cancellationToken)
- => await AIAgentResponsesProcessor.CreateModelResponseAsync(agent, createResponse, cancellationToken).ConfigureAwait(false))
- .WithName(endpointAgentName + "/CreateResponse");
+
+ // Create response endpoint
+ group.MapPost("/", handlers.CreateResponseAsync)
+ .WithName(endpointAgentName + "/CreateResponse")
+ .WithSummary("Creates a model response for the given input");
+
+ // Get response endpoint
+ group.MapGet("{responseId}", handlers.GetResponseAsync)
+ .WithName(endpointAgentName + "/GetResponse")
+ .WithSummary("Retrieves a response by ID");
+
+ // Cancel response endpoint
+ group.MapPost("{responseId}/cancel", handlers.CancelResponseAsync)
+ .WithName(endpointAgentName + "/CancelResponse")
+ .WithSummary("Cancels an in-progress response");
+
+ // Delete response endpoint
+ group.MapDelete("{responseId}", handlers.DeleteResponseAsync)
+ .WithName(endpointAgentName + "/DeleteResponse")
+ .WithSummary("Deletes a response");
+
+ // List response input items endpoint
+ group.MapGet("{responseId}/input_items", handlers.ListResponseInputItemsAsync)
+ .WithName(endpointAgentName + "/ListResponseInputItems")
+ .WithSummary("Lists the input items for a response");
+
return group;
}
@@ -70,24 +101,37 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
ArgumentNullException.ThrowIfNull(endpoints);
responsesPath ??= "/v1/responses";
+ var responsesService = endpoints.ServiceProvider.GetService()
+ ?? throw new InvalidOperationException("IResponsesService is not registered. Call AddOpenAIResponses() in your service configuration.");
+ var handlers = new ResponsesHttpHandler(responsesService);
+
var group = endpoints.MapGroup(responsesPath);
- group.MapPost("/", async ([FromBody] CreateResponse createResponse, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
- {
- // DevUI uses the 'model' field to specify the agent name.
- var agentName = createResponse.Agent?.Name ?? createResponse.Model;
- if (agentName is null)
- {
- return Results.BadRequest("No 'agent.name' or 'model' specified in the request.");
- }
- var agent = serviceProvider.GetKeyedService(agentName);
- if (agent is null)
- {
- return Results.NotFound($"Agent named '{agentName}' was not found.");
- }
+ // Create response endpoint
+ group.MapPost("/", handlers.CreateResponseAsync)
+ .WithName("CreateResponse")
+ .WithSummary("Creates a model response for the given input");
+
+ // Get response endpoint
+ group.MapGet("{responseId}", handlers.GetResponseAsync)
+ .WithName("GetResponse")
+ .WithSummary("Retrieves a response by ID");
+
+ // Cancel response endpoint
+ group.MapPost("{responseId}/cancel", handlers.CancelResponseAsync)
+ .WithName("CancelResponse")
+ .WithSummary("Cancels an in-progress response");
+
+ // Delete response endpoint
+ group.MapDelete("{responseId}", handlers.DeleteResponseAsync)
+ .WithName("DeleteResponse")
+ .WithSummary("Deletes a response");
+
+ // List response input items endpoint
+ group.MapGet("{responseId}/input_items", handlers.ListResponseInputItemsAsync)
+ .WithName("ListResponseInputItems")
+ .WithSummary("Lists the input items for a response");
- return await AIAgentResponsesProcessor.CreateModelResponseAsync(agent, createResponse, cancellationToken).ConfigureAwait(false);
- }).WithName("CreateResponse");
return group;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs
index dc6a9c5ed0..348c83f978 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs
@@ -39,4 +39,18 @@ public static class MicrosoftAgentAIHostingOpenAIHostApplicationBuilderExtension
return builder;
}
+
+ ///
+ /// Adds support for exposing instances via OpenAI Responses.
+ ///
+ /// The to configure.
+ /// The for method chaining.
+ public static IHostApplicationBuilder AddOpenAIConversations(this IHostApplicationBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddOpenAIConversations();
+
+ return builder;
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGenerator.cs
new file mode 100644
index 0000000000..bd35fa8308
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGenerator.cs
@@ -0,0 +1,193 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Security.Cryptography;
+using System.Text.RegularExpressions;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI;
+
+///
+/// Generates IDs with partition keys.
+///
+internal sealed partial class IdGenerator
+{
+ private readonly string _partitionId;
+ private readonly Random? _random;
+
+#if NET9_0_OR_GREATER
+ [GeneratedRegex("^[A-Za-z0-9]+$")]
+ private static partial Regex WatermarkRegex();
+#else
+ private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled);
+ private static Regex WatermarkRegex() => s_watermarkRegex;
+#endif
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The response ID.
+ /// The conversation ID.
+ /// Optional random seed for deterministic ID generation. When null, uses cryptographically secure random generation.
+ public IdGenerator(string? responseId, string? conversationId, int? randomSeed = null)
+ {
+ this._random = randomSeed.HasValue ? new Random(randomSeed.Value) : null;
+ this.ResponseId = responseId ?? NewId("resp", random: this._random);
+ this.ConversationId = conversationId ?? NewId("conv", random: this._random);
+ this._partitionId = GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty;
+ }
+
+ ///
+ /// Creates a new ID generator from a create response request.
+ ///
+ /// The create response request.
+ /// A new ID generator.
+ public static IdGenerator From(CreateResponse request)
+ {
+ string? responseId = null;
+ request.Metadata?.TryGetValue("response_id", out responseId);
+ return new IdGenerator(responseId, request.Conversation?.Id);
+ }
+
+ ///
+ /// Gets the response ID.
+ ///
+ public string ResponseId { get; }
+
+ ///
+ /// Gets the conversation ID.
+ ///
+ public string ConversationId { get; }
+
+ ///
+ /// Generates a new ID.
+ ///
+ /// The optional category for the ID.
+ /// A generated ID string.
+ public string Generate(string? category = null)
+ {
+ var prefix = string.IsNullOrEmpty(category) ? "id" : category;
+ return NewId(prefix, partitionKey: this._partitionId, random: this._random);
+ }
+
+ ///
+ /// Generates a function call ID.
+ ///
+ /// A function call ID.
+ public string GenerateFunctionCallId() => this.Generate("func");
+
+ ///
+ /// Generates a function output ID.
+ ///
+ /// A function output ID.
+ public string GenerateFunctionOutputId() => this.Generate("funcout");
+
+ ///
+ /// Generates a message ID.
+ ///
+ /// A message ID.
+ public string GenerateMessageId() => this.Generate("msg");
+
+ ///
+ /// Generates a reasoning ID.
+ ///
+ /// A reasoning ID.
+ public string GenerateReasoningId() => this.Generate("rs");
+
+ ///
+ /// Generates a new ID with a structured format that includes a partition key.
+ ///
+ /// The prefix to add to the ID, typically indicating the resource type.
+ /// The length of the random entropy string in the ID.
+ /// The length of the partition key if generating a new one.
+ /// Optional additional text to insert between the prefix and the entropy.
+ /// Optional text to insert in the middle of the entropy string for traceability.
+ /// The delimiter character used to separate parts of the ID.
+ /// An explicit partition key to use. When provided, this value will be used instead of generating a new one.
+ /// An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.
+ /// The random number generator.
+ /// A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".
+ /// Thrown when the watermark contains non-alphanumeric characters.
+ public static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "",
+ string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "",
+ Random? random = null)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1);
+ var entropy = GetRandomString(stringLength, random);
+
+ string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength, random);
+
+ if (!string.IsNullOrEmpty(watermark))
+ {
+ if (!WatermarkRegex().IsMatch(watermark))
+ {
+ throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}",
+ nameof(watermark));
+ }
+
+ entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}";
+ }
+
+ infix ??= "";
+ prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : "";
+ return $"{prefix}{infix}{entropy}{pKey}";
+ }
+
+ ///
+ /// Generates a secure random alphanumeric string of the specified length.
+ /// When a random seed was provided to the constructor, uses deterministic generation.
+ ///
+ /// The desired length of the random string.
+ /// The optional random number generator.
+ /// A random alphanumeric string.
+ /// Thrown when stringLength is less than 1.
+ private static string GetRandomString(int stringLength, Random? random)
+ {
+ const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ if (random is not null)
+ {
+ // Use deterministic random generation when seed is provided
+ return string.Create(stringLength, random, static (destination, random) =>
+ {
+ for (int i = 0; i < destination.Length; i++)
+ {
+ destination[i] = Chars[random.Next(Chars.Length)];
+ }
+ });
+ }
+
+ // Use cryptographically secure random generation when no seed is provided
+ return RandomNumberGenerator.GetString(Chars, stringLength);
+ }
+
+ ///
+ /// Extracts the partition key from an existing ID, or returns null if extraction fails.
+ ///
+ /// The ID to extract the partition key from.
+ /// The length of the random entropy string in the ID.
+ /// The length of the partition key if generating a new one.
+ /// The delimiter character used in the ID.
+ /// The partition key if successfully extracted; otherwise, null.
+ private static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16,
+ string delimiter = "_")
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ return null;
+ }
+
+ var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2)
+ {
+ return null;
+ }
+
+ if (parts[1].Length < stringLength + partitionKeyLength)
+ {
+ return null;
+ }
+
+ // get last partitionKeyLength characters from the last part as the partition key
+ return parts[1][^partitionKeyLength..];
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs
deleted file mode 100644
index 6f2e1017d6..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Security.Cryptography;
-using System.Text.RegularExpressions;
-
-namespace Microsoft.Agents.AI.Hosting.OpenAI;
-
-///
-/// Shared helpers to generate IDs.
-///
-internal static partial class IdGeneratorHelpers
-{
-#if NET9_0_OR_GREATER
- [GeneratedRegex("^[A-Za-z0-9]+$")]
- private static partial Regex WatermarkRegex();
-#else
- private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled);
- private static Regex WatermarkRegex() => s_watermarkRegex;
-#endif
-
- ///
- /// Generates a new ID with a structured format that includes a partition key.
- ///
- /// The prefix to add to the ID, typically indicating the resource type.
- /// The length of the random entropy string in the ID.
- /// The length of the partition key if generating a new one.
- /// Optional additional text to insert between the prefix and the entropy.
- /// Optional text to insert in the middle of the entropy string for traceability.
- /// The delimiter character used to separate parts of the ID.
- /// An explicit partition key to use. When provided, this value will be used instead of generating a new one.
- /// An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.
- /// A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".
- /// Thrown when the watermark contains non-alphanumeric characters.
- public static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "",
- string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "")
- {
- ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1);
- var entropy = GetRandomString(stringLength);
-
- string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength);
-
- if (!string.IsNullOrEmpty(watermark))
- {
- if (!WatermarkRegex().IsMatch(watermark))
- {
- throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}",
- nameof(watermark));
- }
-
- entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}";
- }
-
- infix ??= "";
- prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : "";
- return $"{prefix}{infix}{entropy}{pKey}";
- }
-
- ///
- /// Generates a secure random alphanumeric string of the specified length.
- ///
- /// The desired length of the random string.
- /// A random alphanumeric string.
- /// Thrown when stringLength is less than 1.
- public static string GetRandomString(int stringLength) =>
- RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength);
-
- ///
- /// Extracts the partition key from an existing ID, or returns null if extraction fails.
- ///
- /// The ID to extract the partition key from.
- /// The length of the random entropy string in the ID.
- /// The length of the partition key if generating a new one.
- /// The delimiter character used in the ID.
- /// The partition key if successfully extracted; otherwise, null.
- public static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16,
- string delimiter = "_")
- {
- if (string.IsNullOrEmpty(id))
- {
- return null;
- }
-
- var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries);
- if (parts.Length < 2)
- {
- return null;
- }
-
- if (parts[1].Length < stringLength + partitionKeyLength)
- {
- return null;
- }
-
- // get last partitionKeyLength characters from the last part as the partition key
- return parts[1][^partitionKeyLength..];
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/InMemoryStorageOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/InMemoryStorageOptions.cs
new file mode 100644
index 0000000000..f7bb755743
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/InMemoryStorageOptions.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI;
+
+///
+/// Configuration options for in-memory storage implementations.
+///
+internal sealed class InMemoryStorageOptions
+{
+ ///
+ /// Gets or sets the maximum number of items to store in the cache.
+ /// Default is 1000. Set to null for no size limit.
+ ///
+ public long? SizeLimit { get; set; } = 1000;
+
+ ///
+ /// Gets or sets the absolute expiration time for items in storage.
+ /// If specified, items will be expired after this timespan regardless of access.
+ /// Default is null (no absolute expiration).
+ ///
+ public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }
+
+ ///
+ /// Gets or sets the sliding expiration for items in storage.
+ /// Items will be expired if not accessed within this timespan.
+ /// Default is 1 hour.
+ ///
+ public TimeSpan? SlidingExpiration { get; set; } = TimeSpan.FromHours(1);
+
+ ///
+ /// Creates from these options.
+ ///
+ internal MemoryCacheOptions ToMemoryCacheOptions() => new()
+ {
+ SizeLimit = this.SizeLimit
+ };
+
+ ///
+ /// Creates from these options.
+ ///
+ internal MemoryCacheEntryOptions ToMemoryCacheEntryOptions() => new()
+ {
+ AbsoluteExpirationRelativeToNow = this.AbsoluteExpirationRelativeToNow,
+ SlidingExpiration = this.SlidingExpiration,
+ Size = 1
+ };
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/MemoryCacheExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/MemoryCacheExtensions.cs
new file mode 100644
index 0000000000..670223ef4f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/MemoryCacheExtensions.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI;
+
+///
+/// Extension methods for that provide atomic operations.
+///
+///
+/// The standard GetOrCreate method has a race condition where multiple threads can simultaneously
+/// detect that a key doesn't exist and create different instances, with only one being cached.
+/// See: https://github.com/dotnet/runtime/issues/36499
+///
+internal static class MemoryCacheExtensions
+{
+ private static readonly ConcurrentDictionary<(IMemoryCache, object), SemaphoreSlim> s_semaphores = new();
+
+ ///
+ /// Atomically gets the value associated with this key if it exists, or generates a new entry
+ /// using the provided key and a value from the given factory if the key is not found.
+ ///
+ /// The type of the object to get.
+ /// The instance this method extends.
+ /// The key of the entry to look for or create.
+ /// The factory that creates the value associated with this key if the key does not exist in the cache.
+ /// The cancellation token.
+ /// A tuple containing the value and a flag indicating whether it was created (true) or retrieved from cache (false).
+ public static async Task GetOrCreateAtomicAsync(
+ this IMemoryCache memoryCache,
+ object key,
+ Func factory,
+ CancellationToken cancellationToken = default)
+ {
+ // Fast path: check if the value already exists
+ if (memoryCache.TryGetValue(key, out object? value))
+ {
+ Debug.Assert(value is not null);
+ return (T)value;
+ }
+
+ // Get or create a semaphore for this cache key
+ bool isOwner = false;
+ var semaphoreKey = (memoryCache, key);
+ if (!s_semaphores.TryGetValue(semaphoreKey, out SemaphoreSlim? semaphore))
+ {
+ SemaphoreSlim? createdSemaphore = null;
+ semaphore = s_semaphores.GetOrAdd(semaphoreKey, _ => createdSemaphore = new SemaphoreSlim(1));
+
+ // If we created the semaphore that made it into the dictionary, we're the owner
+ if (ReferenceEquals(createdSemaphore, semaphore))
+ {
+ isOwner = true;
+ }
+ else
+ {
+ // Our semaphore wasn't the one stored, so dispose it
+ createdSemaphore?.Dispose();
+ }
+ }
+
+ await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ // Double-check: another thread might have created the value while we were waiting
+ if (!memoryCache.TryGetValue(key, out value))
+ {
+ ICacheEntry entry = memoryCache.CreateEntry(key);
+ entry.SetValue(value = factory(entry));
+ entry.Dispose();
+ Debug.Assert(value is not null);
+ return (T)value;
+ }
+
+ Debug.Assert(value is not null);
+ return (T)value;
+ }
+ finally
+ {
+ // If we were the owner of the semaphore, remove it from the dictionary
+ // This prevents memory leaks from accumulating semaphores for evicted cache entries
+ if (isOwner)
+ {
+ s_semaphores.TryRemove(semaphoreKey, out _);
+ }
+
+ semaphore.Release();
+ }
+ }
+}
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 ee5202c53f..707cc4fe68 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
@@ -3,7 +3,7 @@
$(ProjectsCoreTargetFrameworks)
$(ProjectsDebugCoreTargetFrameworks)
- $(NoWarn);OPENAI001
+ $(NoWarn);OPENAI001;MEAI001
Microsoft.Agents.AI.Hosting.OpenAI
alpha
$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated
@@ -25,6 +25,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/DeleteResponse.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/DeleteResponse.cs
new file mode 100644
index 0000000000..1a13ce1f8c
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/DeleteResponse.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Models;
+
+///
+/// Response for a delete operation.
+///
+internal sealed class DeleteResponse
+{
+ ///
+ /// The ID of the deleted object.
+ ///
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ ///
+ /// The object type.
+ ///
+ [JsonPropertyName("object")]
+ [SuppressMessage("Naming", "CA1720:Identifiers should not match keywords", Justification = "Matches OpenAI API specification")]
+ public required string Object { get; init; }
+
+ ///
+ /// Whether the object was successfully deleted.
+ ///
+ [JsonPropertyName("deleted")]
+ public required bool Deleted { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/ErrorResponse.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/ErrorResponse.cs
new file mode 100644
index 0000000000..9a2417b6af
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/ErrorResponse.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Models;
+
+///
+/// Represents an error response from the OpenAI APIs.
+///
+internal sealed class ErrorResponse
+{
+ ///
+ /// Gets the error details.
+ ///
+ [JsonPropertyName("error")]
+ public required ErrorDetails Error { get; init; }
+}
+
+///
+/// Represents the details of an error.
+///
+internal sealed class ErrorDetails
+{
+ ///
+ /// Gets the error message.
+ ///
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+
+ ///
+ /// Gets the error type.
+ ///
+ [JsonPropertyName("type")]
+ public required string Type { get; init; }
+
+ ///
+ /// Gets the error code.
+ ///
+ [JsonPropertyName("code")]
+ public string? Code { get; init; }
+
+ ///
+ /// Gets the parameter that caused the error.
+ ///
+ [JsonPropertyName("param")]
+ public string? Param { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/ListResponse.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/ListResponse.cs
new file mode 100644
index 0000000000..dd75ff3e18
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/ListResponse.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Models;
+
+///
+/// Generic list response for paginated results.
+/// Used across the OpenAI API for listing resources.
+///
+internal sealed class ListResponse
+{
+ ///
+ /// The object type, always "list".
+ ///
+ [JsonPropertyName("object")]
+ [SuppressMessage("Naming", "CA1720:Identifiers should not match keywords", Justification = "Matches OpenAI API specification")]
+ public string Object => "list";
+
+ ///
+ /// The list of items.
+ ///
+ [JsonPropertyName("data")]
+ public required List Data { get; init; }
+
+ ///
+ /// The ID of the first item in the list.
+ ///
+ [JsonPropertyName("first_id")]
+ public string? FirstId { get; init; }
+
+ ///
+ /// The ID of the last item in the list.
+ ///
+ [JsonPropertyName("last_id")]
+ public string? LastId { get; init; }
+
+ ///
+ /// Whether there are more items available.
+ ///
+ [JsonPropertyName("has_more")]
+ public required bool HasMore { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/SortOrder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/SortOrder.cs
new file mode 100644
index 0000000000..c3b5e258cd
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Models/SortOrder.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Models;
+
+///
+/// Specifies the sort order for list operations.
+///
+[JsonConverter(typeof(SortOrderJsonConverter))]
+internal enum SortOrder
+{
+ ///
+ /// Sort in ascending order (oldest to newest).
+ ///
+ Ascending,
+
+ ///
+ /// Sort in descending order (newest to oldest).
+ ///
+ Descending
+}
+
+///
+/// Custom JSON converter for SortOrder enum to serialize as "asc" and "desc".
+///
+internal sealed class SortOrderJsonConverter : JsonConverter
+{
+ ///
+ public override SortOrder Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+ return value switch
+ {
+ string s when s.Equals("asc", StringComparison.OrdinalIgnoreCase) => SortOrder.Ascending,
+ string s when s.Equals("desc", StringComparison.OrdinalIgnoreCase) => SortOrder.Descending,
+ null => throw new JsonException("SortOrder value cannot be null"),
+ _ => throw new JsonException($"Invalid SortOrder value: {value}")
+ };
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, SortOrder value, JsonSerializerOptions options)
+ {
+ var stringValue = value switch
+ {
+ SortOrder.Ascending => "asc",
+ SortOrder.Descending => "desc",
+ _ => throw new JsonException($"Invalid SortOrder value: {value}")
+ };
+ writer.WriteStringValue(stringValue);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs
similarity index 52%
rename from dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesJsonContext.cs
rename to dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs
index 0b37929b48..ceac8b872f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesJsonContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs
@@ -4,16 +4,54 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
-namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+namespace Microsoft.Agents.AI.Hosting.OpenAI;
+///
+/// Provides JSON serialization options and context for OpenAI Hosting APIs to support AOT and trimming.
+///
+internal static class OpenAIHostingJsonUtilities
+{
+ ///
+ /// Gets the default instance used for OpenAI API serialization.
+ /// Includes support for AIContent types and all OpenAI-related types.
+ ///
+ public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
+
+ private static JsonSerializerOptions CreateDefaultOptions()
+ {
+ JsonSerializerOptions options = new(OpenAIHostingJsonContext.Default.Options);
+ options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
+ options.MakeReadOnly();
+ return options;
+ }
+}
+
+///
+/// Provides a unified JSON serialization context for all OpenAI Hosting APIs to support AOT and trimming.
+/// Combines Conversations and Responses API types.
+///
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- NumberHandling = JsonNumberHandling.AllowReadingFromString,
- AllowOutOfOrderMetadataProperties = true,
- WriteIndented = false)]
-[JsonSerializable(typeof(Dictionary))]
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString,
+ AllowOutOfOrderMetadataProperties = true,
+ WriteIndented = false)]
+// Conversations API types
+[JsonSerializable(typeof(Conversation))]
+[JsonSerializable(typeof(ListResponse))]
+[JsonSerializable(typeof(CreateConversationRequest))]
+[JsonSerializable(typeof(CreateItemsRequest))]
+[JsonSerializable(typeof(UpdateConversationRequest))]
+[JsonSerializable(typeof(ListResponse))]
+[JsonSerializable(typeof(List))]
+// Shared types
+[JsonSerializable(typeof(DeleteResponse))]
+[JsonSerializable(typeof(ErrorResponse))]
+[JsonSerializable(typeof(ErrorDetails))]
+// Responses API types
[JsonSerializable(typeof(CreateResponse))]
[JsonSerializable(typeof(Response))]
[JsonSerializable(typeof(StreamingResponseEvent))]
@@ -40,11 +78,9 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
[JsonSerializable(typeof(ResponseInput))]
[JsonSerializable(typeof(InputMessage))]
[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(InputMessageContent))]
[JsonSerializable(typeof(ResponseStatus))]
-[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(IList))]
+// ItemResource types
[JsonSerializable(typeof(ItemResource))]
[JsonSerializable(typeof(ResponsesMessageItemResource))]
[JsonSerializable(typeof(ResponsesAssistantMessageItemResource))]
@@ -67,8 +103,35 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
[JsonSerializable(typeof(MCPApprovalRequestItemResource))]
[JsonSerializable(typeof(MCPApprovalResponseItemResource))]
[JsonSerializable(typeof(MCPCallItemResource))]
-[JsonSerializable(typeof(IList))]
[JsonSerializable(typeof(List))]
+// ItemParam types
+[JsonSerializable(typeof(ItemParam))]
+[JsonSerializable(typeof(ResponsesMessageItemParam))]
+[JsonSerializable(typeof(ResponsesUserMessageItemParam))]
+[JsonSerializable(typeof(ResponsesAssistantMessageItemParam))]
+[JsonSerializable(typeof(ResponsesSystemMessageItemParam))]
+[JsonSerializable(typeof(ResponsesDeveloperMessageItemParam))]
+[JsonSerializable(typeof(FunctionToolCallItemParam))]
+[JsonSerializable(typeof(FunctionToolCallOutputItemParam))]
+[JsonSerializable(typeof(FileSearchToolCallItemParam))]
+[JsonSerializable(typeof(ComputerToolCallItemParam))]
+[JsonSerializable(typeof(ComputerToolCallOutputItemParam))]
+[JsonSerializable(typeof(WebSearchToolCallItemParam))]
+[JsonSerializable(typeof(ReasoningItemParam))]
+[JsonSerializable(typeof(ItemReferenceItemParam))]
+[JsonSerializable(typeof(ImageGenerationToolCallItemParam))]
+[JsonSerializable(typeof(CodeInterpreterToolCallItemParam))]
+[JsonSerializable(typeof(LocalShellToolCallItemParam))]
+[JsonSerializable(typeof(LocalShellToolCallOutputItemParam))]
+[JsonSerializable(typeof(MCPListToolsItemParam))]
+[JsonSerializable(typeof(MCPApprovalRequestItemParam))]
+[JsonSerializable(typeof(MCPApprovalResponseItemParam))]
+[JsonSerializable(typeof(MCPCallItemParam))]
+[JsonSerializable(typeof(List))]
+// ItemContent types
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(IReadOnlyList))]
+[JsonSerializable(typeof(ItemContent[]))]
[JsonSerializable(typeof(ItemContent))]
[JsonSerializable(typeof(ItemContentInputText))]
[JsonSerializable(typeof(ItemContentInputAudio))]
@@ -82,5 +145,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
[JsonSerializable(typeof(ResponseTextFormatConfigurationText))]
[JsonSerializable(typeof(ResponseTextFormatConfigurationJsonObject))]
[JsonSerializable(typeof(ResponseTextFormatConfigurationJsonSchema))]
+// Common types
+[JsonSerializable(typeof(Dictionary))]
[ExcludeFromCodeCoverage]
-internal sealed partial class ResponsesJsonContext : JsonSerializerContext;
+internal sealed partial class OpenAIHostingJsonContext : JsonSerializerContext;
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
new file mode 100644
index 0000000000..18863034bf
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+
+///
+/// Response executor that uses an AIAgent to execute responses locally.
+/// This is the default implementation for local execution.
+///
+internal sealed class AIAgentResponseExecutor : IResponseExecutor
+{
+ private readonly AIAgent _agent;
+
+ public AIAgentResponseExecutor(AIAgent agent)
+ {
+ ArgumentNullException.ThrowIfNull(agent);
+ this._agent = agent;
+ }
+
+ public async IAsyncEnumerable ExecuteAsync(
+ AgentInvocationContext context,
+ CreateResponse request,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Create options with properties from the request
+ var chatOptions = new ChatOptions
+ {
+ 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);
+
+ // Convert input to chat messages
+ var messages = new List();
+
+ foreach (var inputMessage in request.Input.GetInputMessages())
+ {
+ messages.Add(inputMessage.ToChatMessage());
+ }
+
+ // Use the extension method to convert streaming updates to streaming response events
+ await foreach (var streamingEvent in this._agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
+ .ToStreamingResponseAsync(request, context, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return streamingEvent;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponsesProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponsesProcessor.cs
deleted file mode 100644
index 5178abd8dc..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponsesProcessor.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Linq;
-using System.Net.ServerSentEvents;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Features;
-
-namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
-
-///
-/// OpenAI Responses processor for .
-///
-internal static class AIAgentResponsesProcessor
-{
- public static async Task CreateModelResponseAsync(AIAgent agent, CreateResponse request, CancellationToken cancellationToken)
- {
- ArgumentNullException.ThrowIfNull(agent);
-
- var context = new AgentInvocationContext(idGenerator: IdGenerator.From(request));
- if (request.Stream == true)
- {
- return new StreamingResponse(agent, request, context);
- }
-
- var messages = request.Input.GetInputMessages().Select(i => i.ToChatMessage());
- var response = await agent.RunAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false);
- return Results.Ok(response.ToResponse(request, context));
- }
-
- private sealed class StreamingResponse(AIAgent agent, CreateResponse createResponse, AgentInvocationContext context) : 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().DisableBuffering();
-
- var chatMessages = createResponse.Input.GetInputMessages().Select(i => i.ToChatMessage()).ToList();
- var events = agent.RunStreamingAsync(chatMessages, cancellationToken: cancellationToken)
- .ToStreamingResponseAsync(createResponse, context, cancellationToken)
- .Select(static evt => new SseItem(evt, evt.Type));
- return SseFormatter.WriteAsync(
- source: events,
- destination: response.Body,
- itemFormatter: static (sseItem, bufferWriter) =>
- {
- using var writer = new Utf8JsonWriter(bufferWriter);
- JsonSerializer.Serialize(writer, sseItem.Data, ResponsesJsonContext.Default.StreamingResponseEvent);
- writer.Flush();
- },
- cancellationToken);
- }
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs
index 6db6dbbc51..f21c2e84e9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentInvocationContext.cs
@@ -29,5 +29,5 @@ internal sealed class AgentInvocationContext(IdGenerator idGenerator, JsonSerial
///
/// Gets the JSON serializer options.
///
- public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? ResponsesJsonSerializerOptions.Default;
+ public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? OpenAIHostingJsonUtilities.DefaultOptions;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs
index b276489d00..fedaeae1f4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs
@@ -15,6 +15,8 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
///
internal static class AgentRunResponseExtensions
{
+ private static ChatRole s_DeveloperRole => new("developer");
+
///
/// Converts an AgentRunResponse to a Response model.
///
@@ -44,38 +46,38 @@ internal static class AgentRunResponseExtensions
return new Response
{
- Id = context.ResponseId,
- CreatedAt = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
- Model = request.Agent?.Name ?? request.Model,
- Status = ResponseStatus.Completed,
Agent = request.Agent?.ToAgentId(),
+ Background = request.Background,
Conversation = request.Conversation ?? (context.ConversationId != null ? new ConversationReference { Id = context.ConversationId } : null),
- Metadata = request.Metadata is IReadOnlyDictionary metadata ? new Dictionary(metadata) : [],
+ CreatedAt = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
+ Error = null,
+ Id = context.ResponseId,
Instructions = request.Instructions,
- Temperature = request.Temperature ?? 1.0,
- TopP = request.TopP ?? 1.0,
- Output = output,
- Usage = agentRunResponse.Usage.ToResponseUsage(),
- ParallelToolCalls = request.ParallelToolCalls ?? true,
- Tools = [.. request.Tools ?? []],
- ToolChoice = request.ToolChoice,
- ServiceTier = request.ServiceTier ?? "default",
- Store = request.Store ?? true,
- PreviousResponseId = request.PreviousResponseId,
- Reasoning = request.Reasoning,
- Text = request.Text,
MaxOutputTokens = request.MaxOutputTokens,
+ MaxToolCalls = request.MaxToolCalls,
+ Metadata = request.Metadata is IReadOnlyDictionary metadata ? new Dictionary(metadata) : [],
+ Model = request.Agent?.Name ?? request.Model,
+ Output = output,
+ ParallelToolCalls = request.ParallelToolCalls ?? true,
+ PreviousResponseId = request.PreviousResponseId,
+ Prompt = request.Prompt,
+ PromptCacheKey = request.PromptCacheKey,
+ Reasoning = request.Reasoning,
+ SafetyIdentifier = request.SafetyIdentifier,
+ ServiceTier = request.ServiceTier ?? "default",
+ Status = ResponseStatus.Completed,
+ Store = request.Store ?? true,
+ Temperature = request.Temperature ?? 1.0,
+ Text = request.Text,
+ ToolChoice = request.ToolChoice,
+ Tools = [.. request.Tools ?? []],
+ TopLogprobs = request.TopLogprobs,
+ TopP = request.TopP ?? 1.0,
Truncation = request.Truncation,
+ Usage = agentRunResponse.Usage.ToResponseUsage(),
#pragma warning disable CS0618 // Type or member is obsolete
User = request.User,
#pragma warning restore CS0618 // Type or member is obsolete
- PromptCacheKey = request.PromptCacheKey,
- SafetyIdentifier = request.SafetyIdentifier,
- TopLogprobs = request.TopLogprobs,
- MaxToolCalls = request.MaxToolCalls,
- Background = request.Background,
- Prompt = request.Prompt,
- Error = null
};
}
@@ -88,22 +90,19 @@ internal static class AgentRunResponseExtensions
/// An enumerable of ItemResource objects.
public static IEnumerable ToItemResource(this ChatMessage message, IdGenerator idGenerator, JsonSerializerOptions jsonSerializerOptions)
{
- IList contents = [];
- foreach (var content in message.Contents)
+ List contents = [];
+ foreach (AIContent content in message.Contents)
{
switch (content)
{
case FunctionCallContent functionCallContent:
- // message.Role == ChatRole.Assistant
yield return functionCallContent.ToFunctionToolCallItemResource(idGenerator.GenerateFunctionCallId(), jsonSerializerOptions);
break;
case FunctionResultContent functionResultContent:
- // message.Role == ChatRole.Tool
yield return functionResultContent.ToFunctionToolCallOutputItemResource(
idGenerator.GenerateFunctionOutputId());
break;
default:
- // message.Role == ChatRole.Assistant
if (ItemContentConverter.ToItemContent(content) is { } itemContent)
{
contents.Add(itemContent);
@@ -115,12 +114,34 @@ internal static class AgentRunResponseExtensions
if (contents.Count > 0)
{
- yield return new ResponsesAssistantMessageItemResource
- {
- Id = idGenerator.GenerateMessageId(),
- Status = ResponsesMessageItemResourceStatus.Completed,
- Content = contents
- };
+ List contentArray = contents;
+ string messageId = idGenerator.GenerateMessageId();
+
+ yield return
+ message.Role == ChatRole.User ? new ResponsesUserMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ } :
+ message.Role == ChatRole.System ? new ResponsesSystemMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ } :
+ message.Role == s_DeveloperRole ? new ResponsesDeveloperMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ } :
+ new ResponsesAssistantMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ };
}
}
@@ -168,6 +189,49 @@ internal static class AgentRunResponseExtensions
};
}
+ ///
+ /// Converts an InputMessage to ItemResource objects.
+ ///
+ /// The input message to convert.
+ /// The ID generator to use for creating IDs.
+ /// An enumerable of ItemResource objects.
+ public static IEnumerable ToItemResource(this InputMessage inputMessage, IdGenerator idGenerator)
+ {
+ // Convert InputMessageContent to ItemContent array
+ List contentArray = inputMessage.Content.ToItemContents();
+
+ // Generate a message ID
+ string messageId = idGenerator.GenerateMessageId();
+
+ // Create the appropriate message type based on role
+ ChatRole role = new(inputMessage.Role.Value);
+ yield return
+ role == ChatRole.User ? new ResponsesUserMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ } :
+ role == ChatRole.System ? new ResponsesSystemMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ } :
+ role == s_DeveloperRole ? new ResponsesDeveloperMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ } :
+ new ResponsesAssistantMessageItemResource
+ {
+ Id = messageId,
+ Status = ResponsesMessageItemResourceStatus.Completed,
+ Content = contentArray
+ };
+ }
+
///
/// Converts UsageDetails to ResponseUsage.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs
index fb4ea9a04a..252cdc8d92 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs
@@ -4,11 +4,13 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Streaming;
+using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
@@ -26,7 +28,7 @@ internal static class AgentRunResponseUpdateExtensions
/// The agent invocation context.
/// The cancellation token.
/// A stream of response events.
- internal static async IAsyncEnumerable ToStreamingResponseAsync(
+ public static async IAsyncEnumerable ToStreamingResponseAsync(
this IAsyncEnumerable updates,
CreateResponse request,
AgentInvocationContext context,
@@ -50,6 +52,13 @@ internal static class AgentRunResponseUpdateExtensions
cancellationToken.ThrowIfCancellationRequested();
var update = updateEnumerator.Current;
+ // Special-case for agent framework workflow events.
+ if (update.RawRepresentation is WorkflowEvent workflowEvent)
+ {
+ yield return CreateWorkflowEventResponse(workflowEvent, seq.Increment(), outputIndex);
+ continue;
+ }
+
if (!IsSameMessage(update, previousUpdate))
{
// Finalize the current generator when moving to a new message.
@@ -99,6 +108,8 @@ internal static class AgentRunResponseUpdateExtensions
TextReasoningContent => new TextReasoningContentEventGenerator(context.IdGenerator, seq, outputIndex),
FunctionCallContent => new FunctionCallEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions),
FunctionResultContent => new FunctionResultEventGenerator(context.IdGenerator, seq, outputIndex),
+ FunctionApprovalRequestContent => new FunctionApprovalRequestEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions),
+ FunctionApprovalResponseContent => new FunctionApprovalResponseEventGenerator(context.IdGenerator, seq, outputIndex),
ErrorContent => new ErrorContentEventGenerator(context.IdGenerator, seq, outputIndex),
UriContent uriContent when uriContent.HasTopLevelMediaType("image") => new ImageContentEventGenerator(context.IdGenerator, seq, outputIndex),
DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentEventGenerator(context.IdGenerator, seq, outputIndex),
@@ -144,38 +155,38 @@ internal static class AgentRunResponseUpdateExtensions
{
return new Response
{
- Id = context.ResponseId,
- CreatedAt = createdAt.ToUnixTimeSeconds(),
- Model = request.Agent?.Name ?? request.Model,
- Status = status,
Agent = request.Agent?.ToAgentId(),
+ Background = request.Background,
Conversation = request.Conversation ?? new ConversationReference { Id = context.ConversationId },
- Metadata = request.Metadata != null ? new Dictionary(request.Metadata) : [],
+ CreatedAt = createdAt.ToUnixTimeSeconds(),
+ Error = null,
+ Id = context.ResponseId,
Instructions = request.Instructions,
- Temperature = request.Temperature ?? 1.0,
- TopP = request.TopP ?? 1.0,
- Output = outputs?.ToList() ?? [],
- Usage = latestUsage,
- ParallelToolCalls = request.ParallelToolCalls ?? true,
- Tools = [.. request.Tools ?? []],
- ToolChoice = request.ToolChoice,
- ServiceTier = request.ServiceTier ?? "default",
- Store = request.Store ?? true,
- PreviousResponseId = request.PreviousResponseId,
- Reasoning = request.Reasoning,
- Text = request.Text,
MaxOutputTokens = request.MaxOutputTokens,
+ MaxToolCalls = request.MaxToolCalls,
+ Metadata = request.Metadata != null ? new Dictionary(request.Metadata) : [],
+ Model = request.Agent?.Name ?? request.Model,
+ Output = outputs?.ToList() ?? [],
+ ParallelToolCalls = request.ParallelToolCalls ?? true,
+ PreviousResponseId = request.PreviousResponseId,
+ Prompt = request.Prompt,
+ PromptCacheKey = request.PromptCacheKey,
+ Reasoning = request.Reasoning,
+ SafetyIdentifier = request.SafetyIdentifier,
+ ServiceTier = request.ServiceTier,
+ Status = status,
+ Store = request.Store ?? true,
+ Temperature = request.Temperature ?? 1.0,
+ Text = request.Text,
+ ToolChoice = request.ToolChoice,
+ Tools = [.. request.Tools ?? []],
+ TopLogprobs = request.TopLogprobs,
+ TopP = request.TopP ?? 1.0,
Truncation = request.Truncation,
+ Usage = latestUsage,
#pragma warning disable CS0618 // Type or member is obsolete
User = request.User,
- PromptCacheKey = request.PromptCacheKey,
#pragma warning restore CS0618 // Type or member is obsolete
- SafetyIdentifier = request.SafetyIdentifier,
- TopLogprobs = request.TopLogprobs,
- MaxToolCalls = request.MaxToolCalls,
- Background = request.Background,
- Prompt = request.Prompt,
- Error = null
};
}
}
@@ -192,4 +203,49 @@ internal static class AgentRunResponseUpdateExtensions
static bool IsSameRole(ChatRole? value1, ChatRole? value2) =>
!value1.HasValue || !value2.HasValue || value1.Value == value2.Value;
}
+
+ private static StreamingWorkflowEventComplete CreateWorkflowEventResponse(WorkflowEvent workflowEvent, int sequenceNumber, int outputIndex)
+ {
+ // Extract executor_id if this is an ExecutorEvent
+ string? executorId = null;
+ if (workflowEvent is ExecutorEvent execEvent)
+ {
+ executorId = execEvent.ExecutorId;
+ }
+ JsonElement eventData;
+ if (JsonSerializer.IsReflectionEnabledByDefault)
+ {
+ JsonElement? dataElement = null;
+ if (workflowEvent.Data is not null)
+ {
+ dataElement = JsonSerializer.SerializeToElement(workflowEvent.Data, OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
+ }
+
+ var eventDataObj = new WorkflowEventData
+ {
+ EventType = workflowEvent.GetType().Name,
+ Data = dataElement,
+ ExecutorId = executorId,
+ Timestamp = DateTime.UtcNow.ToString("O")
+ };
+
+ eventData = JsonSerializer.SerializeToElement(eventDataObj, OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(WorkflowEventData)));
+ }
+ else
+ {
+ eventData = JsonSerializer.SerializeToElement(
+ "Unsupported. Workflow event serialization is currently only supported when JsonSerializer.IsReflectionEnabledByDefault is true.",
+ OpenAIHostingJsonContext.Default.String);
+ }
+
+ // Create the properly typed streaming workflow event
+ return new StreamingWorkflowEventComplete
+ {
+ SequenceNumber = sequenceNumber,
+ OutputIndex = outputIndex,
+ Data = eventData,
+ ExecutorId = executorId,
+ ItemId = IdGenerator.NewId(prefix: "wf", stringLength: 8, delimiter: "")
+ };
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs
index cbaf7cb87b..32262d2e2c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs
@@ -11,6 +11,23 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
///
internal static class ItemContentConverter
{
+ private static string AudioFormatToMediaType(string? format) =>
+ format?.Equals("mp3", StringComparison.OrdinalIgnoreCase) == true ? "audio/mpeg" :
+ format?.Equals("wav", StringComparison.OrdinalIgnoreCase) == true ? "audio/wav" :
+ format?.Equals("opus", StringComparison.OrdinalIgnoreCase) == true ? "audio/opus" :
+ format?.Equals("aac", StringComparison.OrdinalIgnoreCase) == true ? "audio/aac" :
+ format?.Equals("flac", StringComparison.OrdinalIgnoreCase) == true ? "audio/flac" :
+ format?.Equals("pcm16", StringComparison.OrdinalIgnoreCase) == true ? "audio/pcm" :
+ "audio/*";
+
+ private static string MediaTypeToAudioFormat(string mediaType) =>
+ mediaType.Equals("audio/mpeg", StringComparison.OrdinalIgnoreCase) ? "mp3" :
+ mediaType.Equals("audio/wav", StringComparison.OrdinalIgnoreCase) ? "wav" :
+ mediaType.Equals("audio/opus", StringComparison.OrdinalIgnoreCase) ? "opus" :
+ mediaType.Equals("audio/aac", StringComparison.OrdinalIgnoreCase) ? "aac" :
+ mediaType.Equals("audio/flac", StringComparison.OrdinalIgnoreCase) ? "flac" :
+ mediaType.Equals("audio/pcm", StringComparison.OrdinalIgnoreCase) ? "pcm16" :
+ "mp3";
///
/// Converts to .
///
@@ -49,16 +66,7 @@ internal static class ItemContentConverter
// Audio content - map to DataContent with media type based on format
ItemContentInputAudio inputAudio =>
- new DataContent(inputAudio.Data, inputAudio.Format?.ToUpperInvariant() switch
- {
- "MP3" => "audio/mpeg",
- "WAV" => "audio/wav",
- "OPUS" => "audio/opus",
- "AAC" => "audio/aac",
- "FLAC" => "audio/flac",
- "PCM16" => "audio/pcm",
- _ => "audio/*"
- }),
+ new DataContent(inputAudio.Data, AudioFormatToMediaType(inputAudio.Format)),
ItemContentOutputAudio outputAudio =>
new DataContent(outputAudio.Data, "audio/*"),
@@ -104,34 +112,28 @@ internal static class ItemContentConverter
ImageUrl = uriContent.Uri?.ToString(),
Detail = GetImageDetail(uriContent)
},
+ HostedFileContent hostedFile =>
+ new ItemContentInputFile
+ {
+ FileId = hostedFile.FileId
+ },
DataContent dataContent when dataContent.HasTopLevelMediaType("image") =>
new ItemContentInputImage
{
ImageUrl = dataContent.Uri,
Detail = GetImageDetail(dataContent)
},
- HostedFileContent hostedFile =>
- new ItemContentInputFile
- {
- FileId = hostedFile.FileId
- },
- DataContent fileData when !fileData.HasTopLevelMediaType("image") && !fileData.HasTopLevelMediaType("audio") =>
- new ItemContentInputFile
- {
- FileData = fileData.Uri,
- Filename = fileData.Name
- },
DataContent audioData when audioData.HasTopLevelMediaType("audio") =>
new ItemContentInputAudio
{
Data = audioData.Uri,
- Format = audioData.MediaType.Equals("audio/mpeg", StringComparison.OrdinalIgnoreCase) ? "mp3" :
- audioData.MediaType.Equals("audio/wav", StringComparison.OrdinalIgnoreCase) ? "wav" :
- audioData.MediaType.Equals("audio/opus", StringComparison.OrdinalIgnoreCase) ? "opus" :
- audioData.MediaType.Equals("audio/aac", StringComparison.OrdinalIgnoreCase) ? "aac" :
- audioData.MediaType.Equals("audio/flac", StringComparison.OrdinalIgnoreCase) ? "flac" :
- audioData.MediaType.Equals("audio/pcm", StringComparison.OrdinalIgnoreCase) ? "pcm16" :
- "mp3" // Default to mp3
+ Format = MediaTypeToAudioFormat(audioData.MediaType)
+ },
+ DataContent fileData =>
+ new ItemContentInputFile
+ {
+ FileData = fileData.Uri,
+ Filename = fileData.Name
},
// Other AIContent types (FunctionCallContent, FunctionResultContent, etc.)
// are handled separately in the Responses API as different ItemResource types, not ItemContent
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemParamConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemParamConverter.cs
new file mode 100644
index 0000000000..9e63bcfd9d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemParamConverter.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
+
+///
+/// JSON converter for ItemParam that handles polymorphic deserialization based on the "type" discriminator.
+///
+internal sealed class ItemParamConverter : JsonConverter
+{
+ public override ItemParam? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var doc = JsonDocument.ParseValue(ref reader);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("type", out var typeElement))
+ {
+ throw new JsonException("ItemParam must have a 'type' property");
+ }
+
+ var type = typeElement.GetString();
+
+ // Use OpenAIJsonContext directly since it has all the ItemParam type metadata
+ return type switch
+ {
+ "message" => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesMessageItemParam),
+ "function_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.FunctionToolCallItemParam),
+ "function_call_output" => doc.Deserialize(OpenAIHostingJsonContext.Default.FunctionToolCallOutputItemParam),
+ "file_search_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.FileSearchToolCallItemParam),
+ "computer_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.ComputerToolCallItemParam),
+ "computer_call_output" => doc.Deserialize(OpenAIHostingJsonContext.Default.ComputerToolCallOutputItemParam),
+ "web_search_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.WebSearchToolCallItemParam),
+ "reasoning" => doc.Deserialize(OpenAIHostingJsonContext.Default.ReasoningItemParam),
+ "item_reference" => doc.Deserialize(OpenAIHostingJsonContext.Default.ItemReferenceItemParam),
+ "image_generation_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.ImageGenerationToolCallItemParam),
+ "code_interpreter_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.CodeInterpreterToolCallItemParam),
+ "local_shell_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.LocalShellToolCallItemParam),
+ "local_shell_call_output" => doc.Deserialize(OpenAIHostingJsonContext.Default.LocalShellToolCallOutputItemParam),
+ "mcp_list_tools" => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPListToolsItemParam),
+ "mcp_approval_request" => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalRequestItemParam),
+ "mcp_approval_response" => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalResponseItemParam),
+ "mcp_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPCallItemParam),
+ _ => null // Ignore unknown types.
+ };
+ }
+
+ public override void Write(Utf8JsonWriter writer, ItemParam value, JsonSerializerOptions options)
+ {
+ // Use OpenAIJsonContext directly to serialize the concrete type
+ JsonSerializer.Serialize(writer, value, OpenAIHostingJsonContext.Default.Options.GetTypeInfo(value.GetType()));
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs
index 2865de63a6..571e45fa1f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
-using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
@@ -11,137 +10,101 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
///
/// JSON converter for ItemResource that handles type discrimination.
///
-[ExcludeFromCodeCoverage]
internal sealed class ItemResourceConverter : JsonConverter
{
- private readonly ResponsesJsonContext _context;
-
- ///
- /// Initializes a new instance of the class.
- ///
- public ItemResourceConverter()
- {
- this._context = ResponsesJsonContext.Default;
- }
-
+ ///
public override ItemResource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- // Clone the reader to peek at the JSON
- Utf8JsonReader readerClone = reader;
+ using var doc = JsonDocument.ParseValue(ref reader);
+ var root = doc.RootElement;
- // Read through the JSON to find the type property
- string? type = null;
-
- if (readerClone.TokenType != JsonTokenType.StartObject)
+ if (!root.TryGetProperty("type", out var typeElement))
{
- throw new JsonException("Expected start of object");
+ throw new JsonException("ItemResource must have a 'type' property");
}
- while (readerClone.Read())
- {
- if (readerClone.TokenType == JsonTokenType.EndObject)
- {
- break;
- }
-
- if (readerClone.TokenType == JsonTokenType.PropertyName)
- {
- string propertyName = readerClone.GetString()!;
- readerClone.Read(); // Move to the value
-
- if (propertyName == "type")
- {
- type = readerClone.GetString();
- break;
- }
-
- if (readerClone.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray)
- {
- // Skip nested objects/arrays
- readerClone.Skip();
- }
- }
- }
+ var type = typeElement.GetString();
// Determine the concrete type based on the type discriminator and deserialize using the source generation context
return type switch
{
- ResponsesMessageItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.ResponsesMessageItemResource),
- FileSearchToolCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.FileSearchToolCallItemResource),
- FunctionToolCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.FunctionToolCallItemResource),
- FunctionToolCallOutputItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.FunctionToolCallOutputItemResource),
- ComputerToolCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.ComputerToolCallItemResource),
- ComputerToolCallOutputItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.ComputerToolCallOutputItemResource),
- WebSearchToolCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.WebSearchToolCallItemResource),
- ReasoningItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.ReasoningItemResource),
- ItemReferenceItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.ItemReferenceItemResource),
- ImageGenerationToolCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.ImageGenerationToolCallItemResource),
- CodeInterpreterToolCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.CodeInterpreterToolCallItemResource),
- LocalShellToolCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.LocalShellToolCallItemResource),
- LocalShellToolCallOutputItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.LocalShellToolCallOutputItemResource),
- MCPListToolsItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.MCPListToolsItemResource),
- MCPApprovalRequestItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.MCPApprovalRequestItemResource),
- MCPApprovalResponseItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.MCPApprovalResponseItemResource),
- MCPCallItemResource.ItemType => JsonSerializer.Deserialize(ref reader, this._context.MCPCallItemResource),
- _ => throw new JsonException($"Unknown item type: {type}")
+ ResponsesMessageItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesMessageItemResource),
+ FileSearchToolCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.FileSearchToolCallItemResource),
+ FunctionToolCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.FunctionToolCallItemResource),
+ FunctionToolCallOutputItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.FunctionToolCallOutputItemResource),
+ ComputerToolCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ComputerToolCallItemResource),
+ ComputerToolCallOutputItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ComputerToolCallOutputItemResource),
+ WebSearchToolCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.WebSearchToolCallItemResource),
+ ReasoningItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ReasoningItemResource),
+ ItemReferenceItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ItemReferenceItemResource),
+ ImageGenerationToolCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ImageGenerationToolCallItemResource),
+ CodeInterpreterToolCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.CodeInterpreterToolCallItemResource),
+ LocalShellToolCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.LocalShellToolCallItemResource),
+ LocalShellToolCallOutputItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.LocalShellToolCallOutputItemResource),
+ MCPListToolsItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPListToolsItemResource),
+ MCPApprovalRequestItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalRequestItemResource),
+ MCPApprovalResponseItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalResponseItemResource),
+ MCPCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPCallItemResource),
+ _ => null
};
}
+ ///
public override void Write(Utf8JsonWriter writer, ItemResource value, JsonSerializerOptions options)
{
// Directly serialize using the appropriate type info from the context
switch (value)
{
case ResponsesMessageItemResource message:
- JsonSerializer.Serialize(writer, message, this._context.ResponsesMessageItemResource);
+ JsonSerializer.Serialize(writer, message, OpenAIHostingJsonContext.Default.ResponsesMessageItemResource);
break;
case FileSearchToolCallItemResource fileSearch:
- JsonSerializer.Serialize(writer, fileSearch, this._context.FileSearchToolCallItemResource);
+ JsonSerializer.Serialize(writer, fileSearch, OpenAIHostingJsonContext.Default.FileSearchToolCallItemResource);
break;
case FunctionToolCallItemResource functionCall:
- JsonSerializer.Serialize(writer, functionCall, this._context.FunctionToolCallItemResource);
+ JsonSerializer.Serialize(writer, functionCall, OpenAIHostingJsonContext.Default.FunctionToolCallItemResource);
break;
case FunctionToolCallOutputItemResource functionOutput:
- JsonSerializer.Serialize(writer, functionOutput, this._context.FunctionToolCallOutputItemResource);
+ JsonSerializer.Serialize(writer, functionOutput, OpenAIHostingJsonContext.Default.FunctionToolCallOutputItemResource);
break;
case ComputerToolCallItemResource computerCall:
- JsonSerializer.Serialize(writer, computerCall, this._context.ComputerToolCallItemResource);
+ JsonSerializer.Serialize(writer, computerCall, OpenAIHostingJsonContext.Default.ComputerToolCallItemResource);
break;
case ComputerToolCallOutputItemResource computerOutput:
- JsonSerializer.Serialize(writer, computerOutput, this._context.ComputerToolCallOutputItemResource);
+ JsonSerializer.Serialize(writer, computerOutput, OpenAIHostingJsonContext.Default.ComputerToolCallOutputItemResource);
break;
case WebSearchToolCallItemResource webSearch:
- JsonSerializer.Serialize(writer, webSearch, this._context.WebSearchToolCallItemResource);
+ JsonSerializer.Serialize(writer, webSearch, OpenAIHostingJsonContext.Default.WebSearchToolCallItemResource);
break;
case ReasoningItemResource reasoning:
- JsonSerializer.Serialize(writer, reasoning, this._context.ReasoningItemResource);
+ JsonSerializer.Serialize(writer, reasoning, OpenAIHostingJsonContext.Default.ReasoningItemResource);
break;
case ItemReferenceItemResource itemReference:
- JsonSerializer.Serialize(writer, itemReference, this._context.ItemReferenceItemResource);
+ JsonSerializer.Serialize(writer, itemReference, OpenAIHostingJsonContext.Default.ItemReferenceItemResource);
break;
case ImageGenerationToolCallItemResource imageGeneration:
- JsonSerializer.Serialize(writer, imageGeneration, this._context.ImageGenerationToolCallItemResource);
+ JsonSerializer.Serialize(writer, imageGeneration, OpenAIHostingJsonContext.Default.ImageGenerationToolCallItemResource);
break;
case CodeInterpreterToolCallItemResource codeInterpreter:
- JsonSerializer.Serialize(writer, codeInterpreter, this._context.CodeInterpreterToolCallItemResource);
+ JsonSerializer.Serialize(writer, codeInterpreter, OpenAIHostingJsonContext.Default.CodeInterpreterToolCallItemResource);
break;
case LocalShellToolCallItemResource localShell:
- JsonSerializer.Serialize(writer, localShell, this._context.LocalShellToolCallItemResource);
+ JsonSerializer.Serialize(writer, localShell, OpenAIHostingJsonContext.Default.LocalShellToolCallItemResource);
break;
case LocalShellToolCallOutputItemResource localShellOutput:
- JsonSerializer.Serialize(writer, localShellOutput, this._context.LocalShellToolCallOutputItemResource);
+ JsonSerializer.Serialize(writer, localShellOutput, OpenAIHostingJsonContext.Default.LocalShellToolCallOutputItemResource);
break;
case MCPListToolsItemResource mcpListTools:
- JsonSerializer.Serialize(writer, mcpListTools, this._context.MCPListToolsItemResource);
+ JsonSerializer.Serialize(writer, mcpListTools, OpenAIHostingJsonContext.Default.MCPListToolsItemResource);
break;
case MCPApprovalRequestItemResource mcpApprovalRequest:
- JsonSerializer.Serialize(writer, mcpApprovalRequest, this._context.MCPApprovalRequestItemResource);
+ JsonSerializer.Serialize(writer, mcpApprovalRequest, OpenAIHostingJsonContext.Default.MCPApprovalRequestItemResource);
break;
case MCPApprovalResponseItemResource mcpApprovalResponse:
- JsonSerializer.Serialize(writer, mcpApprovalResponse, this._context.MCPApprovalResponseItemResource);
+ JsonSerializer.Serialize(writer, mcpApprovalResponse, OpenAIHostingJsonContext.Default.MCPApprovalResponseItemResource);
break;
case MCPCallItemResource mcpCall:
- JsonSerializer.Serialize(writer, mcpCall, this._context.MCPCallItemResource);
+ JsonSerializer.Serialize(writer, mcpCall, OpenAIHostingJsonContext.Default.MCPCallItemResource);
break;
default:
throw new JsonException($"Unknown item type: {value.GetType().Name}");
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ResponsesMessageItemParamConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ResponsesMessageItemParamConverter.cs
new file mode 100644
index 0000000000..18fb1269aa
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ResponsesMessageItemParamConverter.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
+
+///
+/// JSON converter for ResponsesMessageItemParam that handles role-based polymorphic deserialization.
+///
+internal sealed class ResponsesMessageItemParamConverter : JsonConverter
+{
+ ///
+ public override ResponsesMessageItemParam? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var doc = JsonDocument.ParseValue(ref reader);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("role", out var roleElement))
+ {
+ throw new JsonException("ResponsesMessageItemParam must have a 'role' property");
+ }
+
+ var role = roleElement.GetString();
+
+ return role switch
+ {
+ "user" => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesUserMessageItemParam),
+ "assistant" => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesAssistantMessageItemParam),
+ "system" => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesSystemMessageItemParam),
+ "developer" => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesDeveloperMessageItemParam),
+ _ => throw new JsonException($"Unknown message role: {role}")
+ };
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, ResponsesMessageItemParam value, JsonSerializerOptions options)
+ {
+ switch (value)
+ {
+ case ResponsesUserMessageItemParam user:
+ JsonSerializer.Serialize(writer, user, OpenAIHostingJsonContext.Default.ResponsesUserMessageItemParam);
+ break;
+ case ResponsesAssistantMessageItemParam assistant:
+ JsonSerializer.Serialize(writer, assistant, OpenAIHostingJsonContext.Default.ResponsesAssistantMessageItemParam);
+ break;
+ case ResponsesSystemMessageItemParam system:
+ JsonSerializer.Serialize(writer, system, OpenAIHostingJsonContext.Default.ResponsesSystemMessageItemParam);
+ break;
+ case ResponsesDeveloperMessageItemParam developer:
+ JsonSerializer.Serialize(writer, developer, OpenAIHostingJsonContext.Default.ResponsesDeveloperMessageItemParam);
+ break;
+ default:
+ throw new JsonException($"Unknown message type: {value.GetType().Name}");
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ResponsesMessageItemResourceConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ResponsesMessageItemResourceConverter.cs
index c1045f9e56..f6307d6aa3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ResponsesMessageItemResourceConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ResponsesMessageItemResourceConverter.cs
@@ -14,82 +14,47 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
[ExcludeFromCodeCoverage]
internal sealed class ResponsesMessageItemResourceConverter : JsonConverter
{
- private readonly ResponsesJsonContext _context;
-
- ///
- /// Initializes a new instance of the class.
- ///
- public ResponsesMessageItemResourceConverter()
- {
- this._context = ResponsesJsonContext.Default;
- }
-
+ ///
public override ResponsesMessageItemResource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- // Clone the reader to peek at the JSON
- Utf8JsonReader readerClone = reader;
+ using var doc = JsonDocument.ParseValue(ref reader);
+ var root = doc.RootElement;
- // Read through the JSON to find the role property
- string? role = null;
-
- if (readerClone.TokenType != JsonTokenType.StartObject)
+ if (!root.TryGetProperty("role", out var roleElement))
{
- throw new JsonException("Expected start of object");
+ throw new JsonException("ResponsesMessageItemResource must have a 'role' property");
}
- while (readerClone.Read())
- {
- if (readerClone.TokenType == JsonTokenType.EndObject)
- {
- break;
- }
-
- if (readerClone.TokenType == JsonTokenType.PropertyName)
- {
- string propertyName = readerClone.GetString()!;
- readerClone.Read(); // Move to the value
-
- if (propertyName == "role")
- {
- role = readerClone.GetString();
- break;
- }
-
- if (readerClone.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray)
- {
- // Skip nested objects/arrays
- readerClone.Skip();
- }
- }
- }
+ var role = roleElement.GetString();
// Determine the concrete type based on the role and deserialize using the source generation context
return role switch
{
- ResponsesAssistantMessageItemResource.RoleType => JsonSerializer.Deserialize(ref reader, this._context.ResponsesAssistantMessageItemResource),
- ResponsesUserMessageItemResource.RoleType => JsonSerializer.Deserialize(ref reader, this._context.ResponsesUserMessageItemResource),
- ResponsesSystemMessageItemResource.RoleType => JsonSerializer.Deserialize(ref reader, this._context.ResponsesSystemMessageItemResource),
- ResponsesDeveloperMessageItemResource.RoleType => JsonSerializer.Deserialize(ref reader, this._context.ResponsesDeveloperMessageItemResource),
+ ResponsesAssistantMessageItemResource.RoleType => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesAssistantMessageItemResource),
+ ResponsesUserMessageItemResource.RoleType => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesUserMessageItemResource),
+ ResponsesSystemMessageItemResource.RoleType => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesSystemMessageItemResource),
+ ResponsesDeveloperMessageItemResource.RoleType => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesDeveloperMessageItemResource),
_ => throw new JsonException($"Unknown message role: {role}")
};
}
+ ///
public override void Write(Utf8JsonWriter writer, ResponsesMessageItemResource value, JsonSerializerOptions options)
{
// Directly serialize using the appropriate type info from the context
switch (value)
{
case ResponsesAssistantMessageItemResource assistant:
- JsonSerializer.Serialize(writer, assistant, this._context.ResponsesAssistantMessageItemResource);
+ JsonSerializer.Serialize(writer, assistant, OpenAIHostingJsonContext.Default.ResponsesAssistantMessageItemResource);
break;
case ResponsesUserMessageItemResource user:
- JsonSerializer.Serialize(writer, user, this._context.ResponsesUserMessageItemResource);
+ JsonSerializer.Serialize(writer, user, OpenAIHostingJsonContext.Default.ResponsesUserMessageItemResource);
break;
case ResponsesSystemMessageItemResource system:
- JsonSerializer.Serialize(writer, system, this._context.ResponsesSystemMessageItemResource);
+ JsonSerializer.Serialize(writer, system, OpenAIHostingJsonContext.Default.ResponsesSystemMessageItemResource);
break;
case ResponsesDeveloperMessageItemResource developer:
- JsonSerializer.Serialize(writer, developer, this._context.ResponsesDeveloperMessageItemResource);
+ JsonSerializer.Serialize(writer, developer, OpenAIHostingJsonContext.Default.ResponsesDeveloperMessageItemResource);
break;
default:
throw new JsonException($"Unknown message type: {value.GetType().Name}");
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/SnakeCaseEnumConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/SnakeCaseEnumConverter.cs
index fe8812330c..e035e251cb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/SnakeCaseEnumConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/SnakeCaseEnumConverter.cs
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
-using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -11,9 +10,11 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
/// JSON converter for enums that uses snake_case naming convention.
///
/// The enum type to convert.
-[ExcludeFromCodeCoverage]
internal sealed class SnakeCaseEnumConverter : JsonStringEnumConverter where T : struct, Enum
{
+ ///
+ /// Creates a new instance of the class.
+ ///
public SnakeCaseEnumConverter() : base(JsonNamingPolicy.SnakeCaseLower)
{
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
new file mode 100644
index 0000000000..78e4331b6b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+
+///
+/// Response executor that routes requests to hosted AIAgent services based on the model or agent.name parameter.
+/// This executor resolves agents from keyed services registered via AddAIAgent().
+///
+internal sealed class HostedAgentResponseExecutor : IResponseExecutor
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The service provider used to resolve hosted agents.
+ /// The logger instance.
+ public HostedAgentResponseExecutor(
+ IServiceProvider serviceProvider,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(serviceProvider);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ this._serviceProvider = serviceProvider;
+ this._logger = logger;
+ }
+
+ ///
+ public async IAsyncEnumerable ExecuteAsync(
+ AgentInvocationContext context,
+ CreateResponse request,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Validate and resolve agent synchronously to ensure validation errors are thrown immediately
+ AIAgent agent = this.ResolveAgent(request);
+
+ // Create options with properties from the request
+ var chatOptions = new ChatOptions
+ {
+ 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);
+
+ // Convert input to chat messages
+ var messages = new List();
+
+ foreach (var inputMessage in request.Input.GetInputMessages())
+ {
+ messages.Add(inputMessage.ToChatMessage());
+ }
+
+ // Use the extension method to convert streaming updates to streaming response events
+ await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
+ .ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
+ {
+ yield return streamingEvent;
+ }
+ }
+
+ ///
+ /// Resolves an agent from the service provider based on the request.
+ ///
+ /// The create response request.
+ /// The resolved AIAgent instance.
+ /// Thrown when the agent cannot be resolved.
+ private AIAgent ResolveAgent(CreateResponse request)
+ {
+ // Extract agent name from agent.name or model parameter
+ var agentName = request.Agent?.Name ?? request.Model;
+ if (string.IsNullOrEmpty(agentName))
+ {
+ throw new InvalidOperationException("No 'agent.name' or 'model' specified in the request.");
+ }
+
+ // Resolve the keyed agent service
+ try
+ {
+ return this._serviceProvider.GetRequiredKeyedService(agentName);
+ }
+ catch (InvalidOperationException ex)
+ {
+ this._logger.LogError(ex, "Failed to resolve agent with name '{AgentName}'", agentName);
+ throw new InvalidOperationException($"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent().", ex);
+ }
+ }
+
+ ///
+ /// Validates that the agent can be resolved without actually resolving it.
+ /// This allows early validation before starting async execution.
+ ///
+ /// The create response request.
+ /// Thrown when the agent cannot be resolved.
+ public void ValidateAgent(CreateResponse request)
+ {
+ // Use the same logic as ResolveAgent but don't return the agent
+ _ = this.ResolveAgent(request);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs
new file mode 100644
index 0000000000..ca4da70b88
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+
+///
+/// Interface for executing response generation.
+/// Implementations can use local execution (AIAgent) or forward to remote workers.
+///
+internal interface IResponseExecutor
+{
+ ///
+ /// Executes a response generation request and returns streaming events.
+ ///
+ /// The agent invocation context containing the ID generator and other context information.
+ /// The create response request.
+ /// Cancellation token.
+ /// An async enumerable of streaming response events.
+ IAsyncEnumerable ExecuteAsync(
+ AgentInvocationContext context,
+ CreateResponse request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs
new file mode 100644
index 0000000000..67f7b72f20
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+
+///
+/// Service interface for handling OpenAI Responses API operations.
+/// Implementations can use various storage and execution strategies (in-memory, Orleans grains, etc.).
+///
+internal interface IResponsesService
+{
+ ///
+ /// Default limit for list operations.
+ ///
+ const int DefaultListLimit = 20;
+ ///
+ /// Creates a model response for the given input.
+ ///
+ /// The create response request.
+ /// Cancellation token.
+ /// The created response.
+ Task CreateResponseAsync(
+ CreateResponse request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a streaming model response for the given input.
+ ///
+ /// The create response request.
+ /// Cancellation token.
+ /// An async enumerable of streaming response events.
+ IAsyncEnumerable CreateResponseStreamingAsync(
+ CreateResponse request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a response by ID.
+ ///
+ /// The ID of the response to retrieve.
+ /// Cancellation token.
+ /// The response if found, null otherwise.
+ Task GetResponseAsync(
+ string responseId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a response by ID in streaming mode, yielding events as they become available.
+ ///
+ /// The ID of the response to retrieve.
+ /// The sequence number after which to start streaming. If null, starts from the beginning.
+ /// Cancellation token.
+ /// An async enumerable of streaming updates.
+ IAsyncEnumerable GetResponseStreamingAsync(
+ string responseId,
+ int? startingAfter = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Cancels an in-progress response.
+ ///
+ /// The ID of the response to cancel.
+ /// Cancellation token.
+ /// The updated response after cancellation.
+ Task CancelResponseAsync(
+ string responseId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a response by ID.
+ ///
+ /// The ID of the response to delete.
+ /// Cancellation token.
+ /// True if the response was deleted, false if it was not found.
+ Task DeleteResponseAsync(
+ string responseId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the input items for a response.
+ ///
+ /// The ID of the response.
+ /// Maximum number of items to return (1-100). Defaults to if null.
+ /// Sort order. Defaults to if null.
+ /// Return items after this ID.
+ /// Return items before this ID.
+ /// Cancellation token.
+ /// A list response with items and pagination info.
+ Task> ListResponseInputItemsAsync(
+ string responseId,
+ int? limit = null,
+ SortOrder? order = null,
+ string? after = null,
+ string? before = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs
deleted file mode 100644
index c532390371..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
-
-namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
-
-///
-/// Generates IDs with partition keys.
-///
-internal sealed partial class IdGenerator
-{
- private readonly string _partitionId;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The response ID.
- /// The conversation ID.
- public IdGenerator(string? responseId, string? conversationId)
- {
- this.ResponseId = responseId ?? IdGeneratorHelpers.NewId("resp");
- this.ConversationId = conversationId ?? IdGeneratorHelpers.NewId("conv");
- this._partitionId = IdGeneratorHelpers.GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty;
- }
-
- ///
- /// Creates a new ID generator from a create response request.
- ///
- /// The create response request.
- /// A new ID generator.
- public static IdGenerator From(CreateResponse request)
- {
- string? responseId = null;
- request.Metadata?.TryGetValue("response_id", out responseId);
- return new IdGenerator(responseId, request.Conversation?.Id);
- }
-
- ///
- /// Gets the response ID.
- ///
- public string ResponseId { get; }
-
- ///
- /// Gets the conversation ID.
- ///
- public string ConversationId { get; }
-
- ///
- /// Generates a new ID.
- ///
- /// The optional category for the ID.
- /// A generated ID string.
- public string Generate(string? category = null)
- {
- var prefix = string.IsNullOrEmpty(category) ? "id" : category;
- return IdGeneratorHelpers.NewId(prefix, partitionKey: this._partitionId);
- }
-
- ///
- /// Generates a function call ID.
- ///
- /// A function call ID.
- public string GenerateFunctionCallId() => this.Generate("func");
-
- ///
- /// Generates a function output ID.
- ///
- /// A function output ID.
- public string GenerateFunctionOutputId() => this.Generate("funcout");
-
- ///
- /// Generates a message ID.
- ///
- /// A message ID.
- public string GenerateMessageId() => this.Generate("msg");
-
- ///
- /// Generates a reasoning ID.
- ///
- /// A reasoning ID.
- public string GenerateReasoningId() => this.Generate("rs");
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs
new file mode 100644
index 0000000000..dfb744596a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs
@@ -0,0 +1,545 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+
+///
+/// In-memory implementation of responses service for testing and development.
+/// This implementation is thread-safe but data is not persisted across application restarts.
+///
+internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
+{
+ private readonly IResponseExecutor _executor;
+ private readonly MemoryCache _cache;
+ private readonly InMemoryStorageOptions _options;
+ private readonly Conversations.IConversationStorage? _conversationStorage;
+
+ private sealed class ResponseState
+ {
+ private readonly object _lock = new();
+ private TaskCompletionSource _updateSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly Dictionary _outputItems = [];
+
+ public Response? Response { get; set; }
+ public CreateResponse? Request { get; set; }
+ public List StreamingUpdates { get; } = [];
+ public Task? CompletionTask { get; set; }
+ public CancellationTokenSource? CancellationTokenSource { get; set; }
+ public bool IsTerminal => this.Response?.IsTerminal ?? false;
+
+ public void AddStreamingEvent(StreamingResponseEvent streamingEvent)
+ {
+ lock (this._lock)
+ {
+ this.StreamingUpdates.Add(streamingEvent);
+
+ // Update the response object for events that contain it
+ if (streamingEvent is IStreamingResponseEventWithResponse responseEvent)
+ {
+ this.Response = responseEvent.Response;
+ }
+
+ // Track output items as they're added or updated
+ if (streamingEvent is StreamingOutputItemAdded itemAdded)
+ {
+ this._outputItems[itemAdded.OutputIndex] = itemAdded.Item;
+ this.UpdateResponseOutput();
+ }
+ else if (streamingEvent is StreamingOutputItemDone itemDone)
+ {
+ this._outputItems[itemDone.OutputIndex] = itemDone.Item;
+ this.UpdateResponseOutput();
+ }
+ }
+
+ this.SignalUpdate();
+ }
+
+ private void UpdateResponseOutput()
+ {
+ // Update the Response.Output list with current items
+ if (this.Response is not null)
+ {
+ List outputList = [.. this._outputItems.OrderBy(kvp => kvp.Key).Select(kvp => kvp.Value)];
+ this.Response = this.Response with { Output = outputList };
+ }
+ }
+
+ public async IAsyncEnumerable StreamUpdatesAsync(
+ int startingAfter = 0,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ int streamedCount = startingAfter;
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Capture the wait task before checking state to avoid race conditions
+ Task waitTask = this.WaitForUpdateAsync(cancellationToken);
+
+ // Copy any new updates and check terminal state while holding the lock
+ List newUpdates;
+ bool isTerminal;
+ lock (this._lock)
+ {
+ newUpdates = this.StreamingUpdates.Skip(streamedCount).ToList();
+ streamedCount += newUpdates.Count;
+ isTerminal = this.IsTerminal;
+ }
+
+ // Yield the updates outside the lock
+ foreach (StreamingResponseEvent update in newUpdates)
+ {
+ yield return update;
+ }
+
+ // Check if we're done (after yielding any final events)
+ if (isTerminal)
+ {
+ break;
+ }
+
+ // Wait for the next update to be signaled
+ await waitTask.ConfigureAwait(false);
+ }
+ }
+
+ private Task WaitForUpdateAsync(CancellationToken cancellationToken)
+ {
+ Task signalTask = this._updateSignal.Task;
+ return signalTask.WaitAsync(cancellationToken);
+ }
+
+ internal void SignalUpdate()
+ {
+ TaskCompletionSource oldSignal = Interlocked.Exchange(ref this._updateSignal, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
+ oldSignal.TrySetResult();
+ }
+ }
+
+ public InMemoryResponsesService(IResponseExecutor executor)
+ : this(executor, new InMemoryStorageOptions(), null)
+ {
+ }
+
+ public InMemoryResponsesService(IResponseExecutor executor, InMemoryStorageOptions options)
+ : this(executor, options, null)
+ {
+ }
+
+ public InMemoryResponsesService(IResponseExecutor executor, InMemoryStorageOptions options, Conversations.IConversationStorage? conversationStorage)
+ {
+ ArgumentNullException.ThrowIfNull(executor);
+ ArgumentNullException.ThrowIfNull(options);
+ this._executor = executor;
+ this._options = options;
+ this._cache = new MemoryCache(options.ToMemoryCacheOptions());
+ this._conversationStorage = conversationStorage;
+ }
+
+ public async Task CreateResponseAsync(
+ CreateResponse request,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateRequest(request);
+
+ // Validate agent resolution early for HostedAgentResponseExecutor
+ if (this._executor is HostedAgentResponseExecutor hostedExecutor)
+ {
+ hostedExecutor.ValidateAgent(request);
+ }
+
+ if (request.Stream == true)
+ {
+ throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead.");
+ }
+
+ var idGenerator = new IdGenerator(responseId: null, conversationId: request.Conversation?.Id);
+ var responseId = idGenerator.ResponseId;
+ var state = this.InitializeResponse(responseId, request);
+ var ct = request.Background switch
+ {
+ true => CancellationToken.None,
+ _ => cancellationToken,
+ };
+ state.CompletionTask = this.ExecuteResponseAsync(responseId, state, ct);
+
+ // For background responses, start execution and return immediately
+ if (request.Background == true)
+ {
+ return state.Response!;
+ }
+
+ // For non-background responses, wait for completion
+ await state.CompletionTask!.WaitAsync(cancellationToken).ConfigureAwait(false);
+ return state.Response!;
+ }
+
+ public async IAsyncEnumerable CreateResponseStreamingAsync(
+ CreateResponse request,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ ValidateRequest(request);
+
+ if (request.Stream == false)
+ {
+ throw new InvalidOperationException("Cannot create a non-streaming response using CreateResponseStreamingAsync. Use CreateResponseAsync instead.");
+ }
+
+ var idGenerator = new IdGenerator(responseId: null, conversationId: request.Conversation?.Id);
+ var responseId = idGenerator.ResponseId;
+ var state = this.InitializeResponse(responseId, request);
+
+ // Start execution
+ state.CompletionTask = this.ExecuteResponseAsync(responseId, state, CancellationToken.None);
+
+ // Stream updates as they become available
+ await foreach (StreamingResponseEvent update in state.StreamUpdatesAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ public Task GetResponseAsync(string responseId, CancellationToken cancellationToken = default)
+ {
+ this._cache.TryGetValue(responseId, out ResponseState? state);
+ return Task.FromResult(state?.Response);
+ }
+
+ public async IAsyncEnumerable GetResponseStreamingAsync(
+ string responseId,
+ int? startingAfter = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (!this._cache.TryGetValue(responseId, out ResponseState? state) || state is null)
+ {
+ yield break;
+ }
+
+ // Stream existing updates starting from the specified position
+ await foreach (StreamingResponseEvent update in state.StreamUpdatesAsync(startingAfter ?? 0, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ public async Task CancelResponseAsync(string responseId, CancellationToken cancellationToken = default)
+ {
+ if (!this._cache.TryGetValue(responseId, out ResponseState? state) || state is null)
+ {
+ throw new InvalidOperationException($"Response '{responseId}' not found.");
+ }
+
+ if (state.Response is null || state.Response.Background != true)
+ {
+ throw new InvalidOperationException($"Only background responses can be cancelled. Response '{responseId}' was not created with background=true.");
+ }
+
+ if (state.IsTerminal)
+ {
+ throw new InvalidOperationException($"Response '{responseId}' is already in a terminal state and cannot be cancelled.");
+ }
+
+ // Cancel the execution
+ state.CancellationTokenSource?.Cancel();
+
+ if (state.CompletionTask is { } task)
+ {
+ await task.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
+ }
+
+ return state.Response;
+ }
+
+ public Task DeleteResponseAsync(string responseId, CancellationToken cancellationToken = default)
+ {
+ if (!this._cache.TryGetValue(responseId, out ResponseState? state))
+ {
+ return Task.FromResult(false);
+ }
+
+ // Cancel any ongoing execution
+ state?.CancellationTokenSource?.Cancel();
+
+ // Remove the response
+ this._cache.Remove(responseId);
+ return Task.FromResult(true);
+ }
+
+ public Task> ListResponseInputItemsAsync(
+ string responseId,
+ int? limit = null,
+ SortOrder? order = null,
+ string? after = null,
+ string? before = null,
+ CancellationToken cancellationToken = default)
+ {
+ int effectiveLimit = Math.Clamp(limit ?? IResponsesService.DefaultListLimit, 1, 100);
+ SortOrder effectiveOrder = order ?? SortOrder.Descending;
+
+ if (!this._cache.TryGetValue(responseId, out ResponseState? state))
+ {
+ throw new InvalidOperationException($"Response '{responseId}' not found.");
+ }
+
+ if (state is null)
+ {
+ throw new InvalidOperationException($"Response '{responseId}' state is null.");
+ }
+
+ var itemResources = GetInputItems(responseId, state);
+
+ // Apply ordering
+ if (effectiveOrder == SortOrder.Descending)
+ {
+ itemResources.Reverse();
+ }
+
+ // Apply pagination
+ var filtered = itemResources.AsEnumerable();
+
+ if (!string.IsNullOrEmpty(after))
+ {
+ int afterIndex = itemResources.FindIndex(m => m.Id == after);
+ if (afterIndex >= 0)
+ {
+ filtered = itemResources.Skip(afterIndex + 1);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(before))
+ {
+ int beforeIndex = itemResources.FindIndex(m => m.Id == before);
+ if (beforeIndex >= 0)
+ {
+ filtered = filtered.Take(beforeIndex);
+ }
+ }
+
+ var result = filtered.Take(effectiveLimit + 1).ToList();
+ var hasMore = result.Count > effectiveLimit;
+ if (hasMore)
+ {
+ result = result.Take(effectiveLimit).ToList();
+ }
+
+ return Task.FromResult(new ListResponse
+ {
+ Data = result,
+ FirstId = result.FirstOrDefault()?.Id,
+ LastId = result.LastOrDefault()?.Id,
+ HasMore = hasMore
+ });
+ }
+
+ private static void ValidateRequest(CreateResponse request)
+ {
+ if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
+ !string.IsNullOrEmpty(request.PreviousResponseId))
+ {
+ throw new InvalidOperationException("Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'.");
+ }
+ }
+
+ private ResponseState InitializeResponse(string responseId, CreateResponse request)
+ {
+ var metadata = request.Metadata ?? [];
+
+ // Create initial response
+ // Background responses always start as "queued", non-background as "in_progress"
+ var initialStatus = request.Background is true ? ResponseStatus.Queued : ResponseStatus.InProgress;
+ var response = new Response
+ {
+ Agent = request.Agent?.ToAgentId(),
+ Background = request.Background,
+ Conversation = request.Conversation,
+ CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ Error = null,
+ Id = responseId,
+ IncompleteDetails = null,
+ Instructions = request.Instructions,
+ MaxOutputTokens = request.MaxOutputTokens,
+ MaxToolCalls = request.MaxToolCalls,
+ Metadata = metadata,
+ Model = request.Model ?? "default",
+ Output = [],
+ ParallelToolCalls = request.ParallelToolCalls ?? true,
+ PreviousResponseId = request.PreviousResponseId,
+ Prompt = request.Prompt,
+ PromptCacheKey = request.PromptCacheKey,
+ Reasoning = request.Reasoning,
+ SafetyIdentifier = request.SafetyIdentifier,
+ ServiceTier = request.ServiceTier,
+ Status = initialStatus,
+ Store = request.Store,
+ Temperature = request.Temperature,
+ Text = request.Text,
+ ToolChoice = request.ToolChoice,
+ Tools = [.. request.Tools ?? []],
+ TopLogprobs = request.TopLogprobs,
+ TopP = request.TopP,
+ Truncation = request.Truncation,
+ Usage = ResponseUsage.Zero,
+#pragma warning disable CS0618 // Type or member is obsolete
+ User = request.User
+#pragma warning restore CS0618 // Type or member is obsolete
+ };
+
+ var state = new ResponseState
+ {
+ Response = response,
+ Request = request,
+ CancellationTokenSource = new CancellationTokenSource()
+ };
+
+ var entryOptions = this._options.ToMemoryCacheEntryOptions();
+ entryOptions.RegisterPostEvictionCallback((key, value, reason, state) =>
+ {
+ if (value is ResponseState responseState)
+ {
+ responseState.CancellationTokenSource?.Cancel();
+ }
+ });
+
+ this._cache.Set(responseId, state, entryOptions);
+
+ return state;
+ }
+
+ private async Task ExecuteResponseAsync(string responseId, ResponseState state, CancellationToken cancellationToken)
+ {
+ await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
+ var request = state.Request!;
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, state.CancellationTokenSource!.Token);
+
+ try
+ {
+ // Create agent invocation context
+ var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));
+
+ // Collect output items for conversation storage
+ List outputItems = [];
+
+ // Execute using the injected executor
+ await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
+ {
+ state.AddStreamingEvent(streamingEvent);
+
+ // Collect output items
+ if (streamingEvent is StreamingOutputItemDone itemDone)
+ {
+ outputItems.Add(itemDone.Item);
+ }
+ }
+
+ // Add both input and output items to conversation storage if available
+ // This happens AFTER successful execution, in line with OpenAI's behavior
+ if (this._conversationStorage is not null && request.Conversation?.Id is not null)
+ {
+ var inputItems = GetInputItems(responseId, state);
+ var allItems = new List(inputItems.Count + outputItems.Count);
+ allItems.AddRange(inputItems);
+ allItems.AddRange(outputItems);
+
+ if (allItems.Count > 0)
+ {
+ await this._conversationStorage.AddItemsAsync(request.Conversation.Id, allItems, linkedCts.Token).ConfigureAwait(false);
+ }
+ }
+
+ // Update response status to completed if not already in a terminal state
+ if (!state.IsTerminal)
+ {
+ state.Response = state.Response! with
+ {
+ Status = ResponseStatus.Completed
+ };
+
+ var sequenceNumber = state.StreamingUpdates.Count + 1;
+ var completedEvent = new StreamingResponseCompleted
+ {
+ SequenceNumber = sequenceNumber,
+ Response = state.Response
+ };
+
+ state.AddStreamingEvent(completedEvent);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Update response status to cancelled
+ state.Response = state.Response! with
+ {
+ Status = ResponseStatus.Cancelled
+ };
+
+ var sequenceNumber = state.StreamingUpdates.Count + 1;
+ var cancelledEvent = new StreamingResponseCancelled
+ {
+ SequenceNumber = sequenceNumber,
+ Response = state.Response
+ };
+
+ state.AddStreamingEvent(cancelledEvent);
+ }
+ catch (Exception ex)
+ {
+ // Update response status to failed
+ state.Response = state.Response! with
+ {
+ Status = ResponseStatus.Failed,
+ Error = new ResponseError
+ {
+ Code = "execution_error",
+ Message = ex.Message
+ }
+ };
+
+ var sequenceNumber = state.StreamingUpdates.Count + 1;
+ var failedEvent = new StreamingResponseFailed
+ {
+ SequenceNumber = sequenceNumber,
+ Response = state.Response
+ };
+
+ state.AddStreamingEvent(failedEvent);
+ }
+ finally
+ {
+ // Signal one final time to unblock any waiting consumers
+ state.SignalUpdate();
+ }
+ }
+
+ private static List GetInputItems(string responseId, ResponseState state)
+ {
+ var itemResources = new List();
+ if (state.Request is not null)
+ {
+ // Use a deterministic random seed. We add 1 to avoid clashing with the output message ids.
+ var randomSeed = responseId.GetHashCode() + 1;
+ var idGenerator = new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id, randomSeed: randomSeed);
+ foreach (var inputMessage in state.Request.Input.GetInputMessages())
+ {
+ itemResources.AddRange(inputMessage.ToItemResource(idGenerator));
+ }
+ }
+
+ return itemResources;
+ }
+
+ public void Dispose()
+ {
+ this._cache.Dispose();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/AgentId.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/AgentId.cs
index 11882ada6a..eaeb8cd658 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/AgentId.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/AgentId.cs
@@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
///
/// Represents an agent identifier.
///
-internal sealed record AgentId
+internal sealed class AgentId
{
///
/// Initializes a new instance of the class.
@@ -44,7 +44,7 @@ internal sealed record AgentId
///
/// Represents an agent ID type.
///
-internal sealed record AgentIdType
+internal sealed class AgentIdType
{
///
/// Initializes a new instance of the class.
@@ -65,7 +65,7 @@ internal sealed record AgentIdType
///
/// Represents an agent reference.
///
-internal sealed record AgentReference
+internal sealed class AgentReference
{
///
/// The type of the reference (e.g., "agent" or "agent_reference").
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ConversationReference.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ConversationReference.cs
index 7f959fcbfc..dc38375331 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ConversationReference.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ConversationReference.cs
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// Represents a reference to a conversation, which can be either a conversation ID (string) or a conversation object.
///
[JsonConverter(typeof(ConversationReferenceJsonConverter))]
-internal sealed record ConversationReference
+internal sealed class ConversationReference
{
///
/// The conversation ID.
@@ -42,6 +42,7 @@ internal sealed record ConversationReference
///
internal sealed class ConversationReferenceJsonConverter : JsonConverter
{
+ ///
public override ConversationReference? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
@@ -61,7 +62,7 @@ internal sealed class ConversationReferenceJsonConverter : JsonConverter
public override void Write(Utf8JsonWriter writer, ConversationReference value, JsonSerializerOptions options)
{
if (value is null)
@@ -95,7 +97,7 @@ internal sealed class ConversationReferenceJsonConverter : JsonConverter
/// Request to create a model response.
///
-internal sealed record CreateResponse
+internal sealed class CreateResponse
{
///
/// Text, image, or file inputs to the model, used to generate a response.
@@ -65,7 +65,10 @@ internal sealed record CreateResponse
///
/// The unique ID of the previous response to the model. Use this to create multi-turn conversations.
- /// Cannot be used in conjunction with conversation.
+ /// Cannot be used in conjunction with conversation (mutually exclusive).
+ /// The previous_response_id determines the conversation thread context - it follows the response chain,
+ /// not any explicit conversation. Context is maintained through the chain even if the previous response
+ /// was created with a conversation.id.
///
[JsonPropertyName("previous_response_id")]
public string? PreviousResponseId { get; init; }
@@ -98,13 +101,16 @@ internal sealed record CreateResponse
/// Specify additional output data to include in the model response.
///
[JsonPropertyName("include")]
- public IReadOnlyList? Include { get; init; }
+ public List? Include { get; init; }
///
/// The conversation that this response belongs to. Items from this conversation are prepended
/// to input_items for this response request.
/// Can be either a conversation ID (string) or a conversation object with ID and optional metadata.
/// Input items and output items from this response are automatically added to this conversation after this response completes.
+ /// Cannot be used in conjunction with previous_response_id (mutually exclusive).
+ /// Use conversation.id for explicit conversation boundaries and starting new threads.
+ /// Use previous_response_id for simple linear conversation chaining.
///
[JsonPropertyName("conversation")]
public ConversationReference? Conversation { get; init; }
@@ -178,7 +184,7 @@ internal sealed record CreateResponse
/// An array of tools the model may call while generating a response.
///
[JsonPropertyName("tools")]
- public IReadOnlyList? Tools { get; init; }
+ public List? Tools { get; init; }
///
/// How the model should select which tool (or tools) to use when generating a response.
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs
index d15e111ebe..029be0752a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// A message input to the model with a role indicating instruction following hierarchy.
/// Aligns with the OpenAI Responses API InputMessage/EasyInputMessage schema.
///
-internal sealed record InputMessage
+internal sealed class InputMessage
{
///
/// The role of the message input. One of user, assistant, system, or developer.
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessageContent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessageContent.cs
index 524613b7eb..0180ff16ac 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessageContent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessageContent.cs
@@ -22,7 +22,7 @@ internal sealed class InputMessageContent : IEquatable
this.Contents = null;
}
- private InputMessageContent(IReadOnlyList contents)
+ private InputMessageContent(List contents)
{
this.Contents = contents ?? throw new ArgumentNullException(nameof(contents));
this.Text = null;
@@ -36,12 +36,12 @@ internal sealed class InputMessageContent : IEquatable
///
/// Creates an InputMessageContent from a list of ItemContent items.
///
- public static InputMessageContent FromContents(IReadOnlyList contents) => new(contents);
+ public static InputMessageContent FromContents(List contents) => new(contents);
///
/// Creates an InputMessageContent from a list of ItemContent items.
///
- public static InputMessageContent FromContents(params ItemContent[] contents) => new(contents);
+ public static InputMessageContent FromContents(params ItemContent[] contents) => new([.. contents]);
///
/// Implicit conversion from string to InputMessageContent.
@@ -62,12 +62,14 @@ internal sealed class InputMessageContent : IEquatable
/// Gets whether this content is text.
///
[MemberNotNullWhen(true, nameof(Text))]
+ [MemberNotNullWhen(false, nameof(Contents))]
public bool IsText => this.Text is not null;
///
/// Gets whether this content is a list of ItemContent items.
///
[MemberNotNullWhen(true, nameof(Contents))]
+ [MemberNotNullWhen(false, nameof(Text))]
public bool IsContents => this.Contents is not null;
///
@@ -78,7 +80,7 @@ internal sealed class InputMessageContent : IEquatable
///
/// Gets the ItemContent items, or null if this is not a content list.
///
- public IReadOnlyList? Contents { get; }
+ public List? Contents { get; }
///
public bool Equals(InputMessageContent? other)
@@ -143,6 +145,16 @@ internal sealed class InputMessageContent : IEquatable
{
return !Equals(left, right);
}
+
+ ///
+ /// Converts this instance to a list of ItemContent.
+ ///
+ public List ToItemContents()
+ {
+ return this.IsText
+ ? [new ItemContentInputText { Text = this.Text }]
+ : this.Contents;
+ }
}
///
@@ -150,6 +162,7 @@ internal sealed class InputMessageContent : IEquatable
///
internal sealed class InputMessageContentJsonConverter : JsonConverter
{
+ ///
public override InputMessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Check if it's a string
@@ -162,7 +175,7 @@ internal sealed class InputMessageContentJsonConverter : JsonConverter 0
? InputMessageContent.FromContents(contents)
: InputMessageContent.FromText(string.Empty);
@@ -171,6 +184,7 @@ internal sealed class InputMessageContentJsonConverter : JsonConverter
public override void Write(Utf8JsonWriter writer, InputMessageContent value, JsonSerializerOptions options)
{
if (value.IsText)
@@ -179,7 +193,7 @@ internal sealed class InputMessageContentJsonConverter : JsonConverter
+/// Base class for all item parameters (input items for creating conversation items or response inputs).
+/// Unlike ItemResource, ItemParam does not have an ID field - the server generates IDs upon creation.
+///
+[JsonConverter(typeof(ItemParamConverter))]
+internal abstract class ItemParam
+{
+ ///
+ /// The type of the item.
+ ///
+ [JsonPropertyName("type")]
+ public abstract string Type { get; }
+}
+
+///
+/// Base class for message item parameters.
+///
+[JsonConverter(typeof(ResponsesMessageItemParamConverter))]
+internal abstract class ResponsesMessageItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for message items.
+ ///
+ public const string ItemType = "message";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The role of the message sender.
+ ///
+ [JsonPropertyName("role")]
+ public abstract ChatRole Role { get; }
+}
+
+///
+/// A user message item parameter.
+///
+internal sealed class ResponsesUserMessageItemParam : ResponsesMessageItemParam
+{
+ ///
+ /// The constant role type identifier for user messages.
+ ///
+ public const string RoleType = "user";
+
+ ///
+ public override ChatRole Role => ChatRole.User;
+
+ ///
+ /// The content of the message. Can be a simple string or an array of content parts.
+ ///
+ [JsonPropertyName("content")]
+ public required InputMessageContent Content { get; init; }
+}
+
+///
+/// An assistant message item parameter.
+///
+internal sealed class ResponsesAssistantMessageItemParam : ResponsesMessageItemParam
+{
+ ///
+ /// The constant role type identifier for assistant messages.
+ ///
+ public const string RoleType = "assistant";
+
+ ///
+ public override ChatRole Role => ChatRole.Assistant;
+
+ ///
+ /// The content of the message. Can be a simple string or an array of content parts.
+ ///
+ [JsonPropertyName("content")]
+ public required InputMessageContent Content { get; init; }
+}
+
+///
+/// A system message item parameter.
+///
+internal sealed class ResponsesSystemMessageItemParam : ResponsesMessageItemParam
+{
+ ///
+ /// The constant role type identifier for system messages.
+ ///
+ public const string RoleType = "system";
+
+ ///
+ public override ChatRole Role => ChatRole.System;
+
+ ///
+ /// The content of the message. Can be a simple string or an array of content parts.
+ ///
+ [JsonPropertyName("content")]
+ public required InputMessageContent Content { get; init; }
+}
+
+///
+/// A developer message item parameter.
+///
+internal sealed class ResponsesDeveloperMessageItemParam : ResponsesMessageItemParam
+{
+ ///
+ /// The constant role type identifier for developer messages.
+ ///
+ public const string RoleType = "developer";
+
+ ///
+ public override ChatRole Role => new(RoleType);
+
+ ///
+ /// The content of the message. Can be a simple string or an array of content parts.
+ ///
+ [JsonPropertyName("content")]
+ public required InputMessageContent Content { get; init; }
+}
+
+///
+/// A function tool call item parameter.
+///
+internal sealed class FunctionToolCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for function call items.
+ ///
+ public const string ItemType = "function_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The call ID of the function.
+ ///
+ [JsonPropertyName("call_id")]
+ public required string CallId { get; init; }
+
+ ///
+ /// The name of the function.
+ ///
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ ///
+ /// The arguments to the function.
+ ///
+ [JsonPropertyName("arguments")]
+ public required string Arguments { get; init; }
+}
+
+///
+/// A function tool call output item parameter.
+///
+internal sealed class FunctionToolCallOutputItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for function call output items.
+ ///
+ public const string ItemType = "function_call_output";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The call ID of the function.
+ ///
+ [JsonPropertyName("call_id")]
+ public required string CallId { get; init; }
+
+ ///
+ /// The output of the function.
+ ///
+ [JsonPropertyName("output")]
+ public required string Output { get; init; }
+}
+
+///
+/// A file search tool call item parameter.
+///
+internal sealed class FileSearchToolCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for file search call items.
+ ///
+ public const string ItemType = "file_search_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The queries used to search for files.
+ ///
+ [JsonPropertyName("queries")]
+ public List? Queries { get; init; }
+
+ ///
+ /// The results of the file search tool call.
+ ///
+ [JsonPropertyName("results")]
+ public List? Results { get; init; }
+}
+
+///
+/// A computer tool call item parameter.
+///
+internal sealed class ComputerToolCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for computer call items.
+ ///
+ public const string ItemType = "computer_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// An identifier used when responding to the tool call with output.
+ ///
+ [JsonPropertyName("call_id")]
+ public required string CallId { get; init; }
+
+ ///
+ /// The action to perform.
+ ///
+ [JsonPropertyName("action")]
+ public required JsonElement Action { get; init; }
+
+ ///
+ /// The pending safety checks for the computer call.
+ ///
+ [JsonPropertyName("pending_safety_checks")]
+ public List? PendingSafetyChecks { get; init; }
+}
+
+///
+/// A computer tool call output item parameter.
+///
+internal sealed class ComputerToolCallOutputItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for computer call output items.
+ ///
+ public const string ItemType = "computer_call_output";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The ID of the computer tool call that produced the output.
+ ///
+ [JsonPropertyName("call_id")]
+ public required string CallId { get; init; }
+
+ ///
+ /// The safety checks reported by the API that have been acknowledged by the developer.
+ ///
+ [JsonPropertyName("acknowledged_safety_checks")]
+ public List? AcknowledgedSafetyChecks { get; init; }
+
+ ///
+ /// The output of the computer tool call.
+ ///
+ [JsonPropertyName("output")]
+ public required JsonElement Output { get; init; }
+}
+
+///
+/// A web search tool call item parameter.
+///
+internal sealed class WebSearchToolCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for web search call items.
+ ///
+ public const string ItemType = "web_search_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// An object describing the specific action taken in this web search call.
+ ///
+ [JsonPropertyName("action")]
+ public required JsonElement Action { get; init; }
+}
+
+///
+/// A reasoning item parameter.
+///
+internal sealed class ReasoningItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for reasoning items.
+ ///
+ public const string ItemType = "reasoning";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The encrypted content of the reasoning item.
+ ///
+ [JsonPropertyName("encrypted_content")]
+ public string? EncryptedContent { get; init; }
+
+ ///
+ /// Reasoning text contents.
+ ///
+ [JsonPropertyName("summary")]
+ public List? Summary { get; init; }
+}
+
+///
+/// An item reference item parameter.
+///
+internal sealed class ItemReferenceItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for item reference items.
+ ///
+ public const string ItemType = "item_reference";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The service-originated ID of the previously generated response item being referenced.
+ ///
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+}
+
+///
+/// An image generation tool call item parameter.
+///
+internal sealed class ImageGenerationToolCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for image generation call items.
+ ///
+ public const string ItemType = "image_generation_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The generated image encoded in base64.
+ ///
+ [JsonPropertyName("result")]
+ public string? Result { get; init; }
+}
+
+///
+/// A code interpreter tool call item parameter.
+///
+internal sealed class CodeInterpreterToolCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for code interpreter call items.
+ ///
+ public const string ItemType = "code_interpreter_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The ID of the container used to run the code.
+ ///
+ [JsonPropertyName("container_id")]
+ public string? ContainerId { get; init; }
+
+ ///
+ /// The code to run, or null if not available.
+ ///
+ [JsonPropertyName("code")]
+ public string? Code { get; init; }
+
+ ///
+ /// The outputs generated by the code interpreter, such as logs or images.
+ /// Can be null if no outputs are available.
+ ///
+ [JsonPropertyName("outputs")]
+ public List? Outputs { get; init; }
+}
+
+///
+/// A local shell tool call item parameter.
+///
+internal sealed class LocalShellToolCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for local shell call items.
+ ///
+ public const string ItemType = "local_shell_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The unique ID of the local shell tool call generated by the model.
+ ///
+ [JsonPropertyName("call_id")]
+ public string? CallId { get; init; }
+
+ ///
+ /// The action to execute.
+ ///
+ [JsonPropertyName("action")]
+ public JsonElement? Action { get; init; }
+}
+
+///
+/// A local shell tool call output item parameter.
+///
+internal sealed class LocalShellToolCallOutputItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for local shell call output items.
+ ///
+ public const string ItemType = "local_shell_call_output";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// A JSON string of the output of the local shell tool call.
+ ///
+ [JsonPropertyName("output")]
+ public string? Output { get; init; }
+}
+
+///
+/// An MCP list tools item parameter.
+///
+internal sealed class MCPListToolsItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for MCP list tools items.
+ ///
+ public const string ItemType = "mcp_list_tools";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The label of the MCP server.
+ ///
+ [JsonPropertyName("server_label")]
+ public string? ServerLabel { get; init; }
+
+ ///
+ /// The tools available on the server.
+ ///
+ [JsonPropertyName("tools")]
+ public List? Tools { get; init; }
+
+ ///
+ /// Error message if the server could not list tools.
+ ///
+ [JsonPropertyName("error")]
+ public string? Error { get; init; }
+}
+
+///
+/// An MCP approval request item parameter.
+///
+internal sealed class MCPApprovalRequestItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for MCP approval request items.
+ ///
+ public const string ItemType = "mcp_approval_request";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The label of the MCP server making the request.
+ ///
+ [JsonPropertyName("server_label")]
+ public string? ServerLabel { get; init; }
+
+ ///
+ /// The name of the tool to run.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ ///
+ /// A JSON string of arguments for the tool.
+ ///
+ [JsonPropertyName("arguments")]
+ public string? Arguments { get; init; }
+}
+
+///
+/// An MCP approval response item parameter.
+///
+internal sealed class MCPApprovalResponseItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for MCP approval response items.
+ ///
+ public const string ItemType = "mcp_approval_response";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The ID of the approval request being answered.
+ ///
+ [JsonPropertyName("approval_request_id")]
+ public string? ApprovalRequestId { get; init; }
+
+ ///
+ /// Whether the request was approved.
+ ///
+ [JsonPropertyName("approve")]
+ public bool? Approve { get; init; }
+
+ ///
+ /// Optional reason for the decision.
+ ///
+ [JsonPropertyName("reason")]
+ public string? Reason { get; init; }
+}
+
+///
+/// An MCP call item parameter.
+///
+internal sealed class MCPCallItemParam : ItemParam
+{
+ ///
+ /// The constant item type identifier for MCP call items.
+ ///
+ public const string ItemType = "mcp_call";
+
+ ///
+ public override string Type => ItemType;
+
+ ///
+ /// The label of the MCP server running the tool.
+ ///
+ [JsonPropertyName("server_label")]
+ public string? ServerLabel { get; init; }
+
+ ///
+ /// The name of the tool that was run.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ ///
+ /// A JSON string of the arguments passed to the tool.
+ ///
+ [JsonPropertyName("arguments")]
+ public string? Arguments { get; init; }
+
+ ///
+ /// The output from the tool call.
+ ///
+ [JsonPropertyName("output")]
+ public string? Output { get; init; }
+
+ ///
+ /// The error from the tool call, if any.
+ ///
+ [JsonPropertyName("error")]
+ public string? Error { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemParamExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemParamExtensions.cs
new file mode 100644
index 0000000000..e8ab3694aa
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemParamExtensions.cs
@@ -0,0 +1,157 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+///
+/// Extension methods for converting ItemParam (input) to ItemResource (output).
+///
+internal static class ItemParamExtensions
+{
+ ///
+ /// Converts an ItemParam (input model) to an ItemResource (output model) by adding server-generated fields.
+ ///
+ /// The input item parameter.
+ /// The ID generator to use for creating item IDs.
+ /// An ItemResource with a generated ID.
+ public static ItemResource ToItemResource(this ItemParam param, IdGenerator idGenerator)
+ {
+ ArgumentNullException.ThrowIfNull(param);
+ ArgumentNullException.ThrowIfNull(idGenerator);
+
+ string generatedId = idGenerator.GenerateMessageId();
+
+ return param switch
+ {
+ ResponsesUserMessageItemParam userMessageParam => new ResponsesUserMessageItemResource
+ {
+ Id = generatedId,
+ Content = userMessageParam.Content.ToItemContents(),
+ Status = ResponsesMessageItemResourceStatus.Completed
+ },
+ ResponsesSystemMessageItemParam systemMessageParam => new ResponsesSystemMessageItemResource
+ {
+ Id = generatedId,
+ Content = systemMessageParam.Content.ToItemContents(),
+ Status = ResponsesMessageItemResourceStatus.Completed
+ },
+ ResponsesAssistantMessageItemParam assistantMessageParam => new ResponsesAssistantMessageItemResource
+ {
+ Id = generatedId,
+ Content = assistantMessageParam.Content.ToItemContents(),
+ Status = ResponsesMessageItemResourceStatus.Completed
+ },
+ ResponsesDeveloperMessageItemParam developerMessageParam => new ResponsesDeveloperMessageItemResource
+ {
+ Id = generatedId,
+ Content = developerMessageParam.Content.ToItemContents(),
+ Status = ResponsesMessageItemResourceStatus.Completed
+ },
+ FunctionToolCallItemParam functionCallParam => new FunctionToolCallItemResource
+ {
+ Id = generatedId,
+ Name = functionCallParam.Name,
+ CallId = functionCallParam.CallId,
+ Arguments = functionCallParam.Arguments,
+ Status = FunctionToolCallItemResourceStatus.Completed
+ },
+ FunctionToolCallOutputItemParam functionOutputParam => new FunctionToolCallOutputItemResource
+ {
+ Id = generatedId,
+ CallId = functionOutputParam.CallId,
+ Output = functionOutputParam.Output
+ },
+ FileSearchToolCallItemParam fileSearchParam => new FileSearchToolCallItemResource
+ {
+ Id = generatedId,
+ Queries = fileSearchParam.Queries,
+ Results = fileSearchParam.Results
+ },
+ ComputerToolCallItemParam computerCallParam => new ComputerToolCallItemResource
+ {
+ Id = generatedId,
+ CallId = computerCallParam.CallId,
+ Action = computerCallParam.Action,
+ PendingSafetyChecks = computerCallParam.PendingSafetyChecks
+ },
+ ComputerToolCallOutputItemParam computerOutputParam => new ComputerToolCallOutputItemResource
+ {
+ Id = generatedId,
+ CallId = computerOutputParam.CallId,
+ AcknowledgedSafetyChecks = computerOutputParam.AcknowledgedSafetyChecks,
+ Output = computerOutputParam.Output
+ },
+ WebSearchToolCallItemParam webSearchParam => new WebSearchToolCallItemResource
+ {
+ Id = generatedId,
+ Action = webSearchParam.Action
+ },
+ ReasoningItemParam reasoningParam => new ReasoningItemResource
+ {
+ Id = generatedId,
+ EncryptedContent = reasoningParam.EncryptedContent,
+ Summary = reasoningParam.Summary
+ },
+ ItemReferenceItemParam => new ItemReferenceItemResource
+ {
+ Id = generatedId
+ },
+ ImageGenerationToolCallItemParam imageGenParam => new ImageGenerationToolCallItemResource
+ {
+ Id = generatedId,
+ Result = imageGenParam.Result
+ },
+ CodeInterpreterToolCallItemParam codeInterpreterParam => new CodeInterpreterToolCallItemResource
+ {
+ Id = generatedId,
+ ContainerId = codeInterpreterParam.ContainerId,
+ Code = codeInterpreterParam.Code,
+ Outputs = codeInterpreterParam.Outputs
+ },
+ LocalShellToolCallItemParam localShellParam => new LocalShellToolCallItemResource
+ {
+ Id = generatedId,
+ CallId = localShellParam.CallId,
+ Action = localShellParam.Action
+ },
+ LocalShellToolCallOutputItemParam localShellOutputParam => new LocalShellToolCallOutputItemResource
+ {
+ Id = generatedId,
+ Output = localShellOutputParam.Output
+ },
+ MCPListToolsItemParam mcpListToolsParam => new MCPListToolsItemResource
+ {
+ Id = generatedId,
+ ServerLabel = mcpListToolsParam.ServerLabel,
+ Tools = mcpListToolsParam.Tools,
+ Error = mcpListToolsParam.Error
+ },
+ MCPApprovalRequestItemParam mcpApprovalRequestParam => new MCPApprovalRequestItemResource
+ {
+ Id = generatedId,
+ ServerLabel = mcpApprovalRequestParam.ServerLabel,
+ Name = mcpApprovalRequestParam.Name,
+ Arguments = mcpApprovalRequestParam.Arguments
+ },
+ MCPApprovalResponseItemParam mcpApprovalResponseParam => new MCPApprovalResponseItemResource
+ {
+ Id = generatedId,
+ ApprovalRequestId = mcpApprovalResponseParam.ApprovalRequestId,
+ Approve = mcpApprovalResponseParam.Approve,
+ Reason = mcpApprovalResponseParam.Reason
+ },
+ MCPCallItemParam mcpCallParam => new MCPCallItemResource
+ {
+ Id = generatedId,
+ ServerLabel = mcpCallParam.ServerLabel,
+ Name = mcpCallParam.Name,
+ Arguments = mcpCallParam.Arguments,
+ Output = mcpCallParam.Output,
+ Error = mcpCallParam.Error
+ },
+ // Fallback for unknown types
+ _ => throw new InvalidOperationException($"Unknown ItemParam type: {param.GetType().Name}")
+ };
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs
index f0a7e83666..0a543e1be9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// Base class for all item resources (output items from a response).
///
[JsonConverter(typeof(ItemResourceConverter))]
-internal abstract record ItemResource
+internal abstract class ItemResource
{
///
/// The unique identifier for the item.
@@ -31,7 +31,7 @@ internal abstract record ItemResource
/// Base class for message item resources.
///
[JsonConverter(typeof(ResponsesMessageItemResourceConverter))]
-internal abstract record ResponsesMessageItemResource : ItemResource
+internal abstract class ResponsesMessageItemResource : ItemResource
{
///
/// The constant item type identifier for message items.
@@ -57,7 +57,7 @@ internal abstract record ResponsesMessageItemResource : ItemResource
///
/// An assistant message item resource.
///
-internal sealed record ResponsesAssistantMessageItemResource : ResponsesMessageItemResource
+internal sealed class ResponsesAssistantMessageItemResource : ResponsesMessageItemResource
{
///
/// The constant role type identifier for assistant messages.
@@ -71,13 +71,13 @@ internal sealed record ResponsesAssistantMessageItemResource : ResponsesMessageI
/// The content of the message.
///
[JsonPropertyName("content")]
- public required IList Content { get; init; }
+ public required List Content { get; init; }
}
///
/// A user message item resource.
///
-internal sealed record ResponsesUserMessageItemResource : ResponsesMessageItemResource
+internal sealed class ResponsesUserMessageItemResource : ResponsesMessageItemResource
{
///
/// The constant role type identifier for user messages.
@@ -91,13 +91,13 @@ internal sealed record ResponsesUserMessageItemResource : ResponsesMessageItemRe
/// The content of the message.
///
[JsonPropertyName("content")]
- public required IList Content { get; init; }
+ public required List Content { get; init; }
}
///
/// A system message item resource.
///
-internal sealed record ResponsesSystemMessageItemResource : ResponsesMessageItemResource
+internal sealed class ResponsesSystemMessageItemResource : ResponsesMessageItemResource
{
///
/// The constant role type identifier for system messages.
@@ -111,13 +111,13 @@ internal sealed record ResponsesSystemMessageItemResource : ResponsesMessageItem
/// The content of the message.
///
[JsonPropertyName("content")]
- public required IList Content { get; init; }
+ public required List Content { get; init; }
}
///
/// A developer message item resource.
///
-internal sealed record ResponsesDeveloperMessageItemResource : ResponsesMessageItemResource
+internal sealed class ResponsesDeveloperMessageItemResource : ResponsesMessageItemResource
{
///
/// The constant role type identifier for developer messages.
@@ -131,13 +131,13 @@ internal sealed record ResponsesDeveloperMessageItemResource : ResponsesMessageI
/// The content of the message.
///
[JsonPropertyName("content")]
- public required IList Content { get; init; }
+ public required List Content { get; init; }
}
///
/// A function tool call item resource.
///
-internal sealed record FunctionToolCallItemResource : ItemResource
+internal sealed class FunctionToolCallItemResource : ItemResource
{
///
/// The constant item type identifier for function call items.
@@ -175,7 +175,7 @@ internal sealed record FunctionToolCallItemResource : ItemResource
///
/// A function tool call output item resource.
///
-internal sealed record FunctionToolCallOutputItemResource : ItemResource
+internal sealed class FunctionToolCallOutputItemResource : ItemResource
{
///
/// The constant item type identifier for function call output items.
@@ -208,7 +208,7 @@ internal sealed record FunctionToolCallOutputItemResource : ItemResource
/// The status of a message item resource.
///
[JsonConverter(typeof(SnakeCaseEnumConverter))]
-public enum ResponsesMessageItemResourceStatus
+internal enum ResponsesMessageItemResourceStatus
{
///
/// The message is completed.
@@ -230,7 +230,7 @@ public enum ResponsesMessageItemResourceStatus
/// The status of a function tool call item resource.
///
[JsonConverter(typeof(SnakeCaseEnumConverter))]
-public enum FunctionToolCallItemResourceStatus
+internal enum FunctionToolCallItemResourceStatus
{
///
/// The function call is completed.
@@ -247,7 +247,7 @@ public enum FunctionToolCallItemResourceStatus
/// The status of a function tool call output item resource.
///
[JsonConverter(typeof(SnakeCaseEnumConverter))]
-public enum FunctionToolCallOutputItemResourceStatus
+internal enum FunctionToolCallOutputItemResourceStatus
{
///
/// The function call output is completed.
@@ -266,7 +266,7 @@ public enum FunctionToolCallOutputItemResourceStatus
[JsonDerivedType(typeof(ItemContentOutputText), "output_text")]
[JsonDerivedType(typeof(ItemContentOutputAudio), "output_audio")]
[JsonDerivedType(typeof(ItemContentRefusal), "refusal")]
-internal abstract record ItemContent
+internal abstract class ItemContent
{
///
/// The type of the content.
@@ -285,7 +285,7 @@ internal abstract record ItemContent
///
/// Text input content.
///
-internal sealed record ItemContentInputText : ItemContent
+internal sealed class ItemContentInputText : ItemContent
{
///
[JsonIgnore]
@@ -301,7 +301,7 @@ internal sealed record ItemContentInputText : ItemContent
///
/// Audio input content.
///
-internal sealed record ItemContentInputAudio : ItemContent
+internal sealed class ItemContentInputAudio : ItemContent
{
///
[JsonIgnore]
@@ -323,7 +323,7 @@ internal sealed record ItemContentInputAudio : ItemContent
///
/// Image input content.
///
-internal sealed record ItemContentInputImage : ItemContent
+internal sealed class ItemContentInputImage : ItemContent
{
///
[JsonIgnore]
@@ -352,7 +352,7 @@ internal sealed record ItemContentInputImage : ItemContent
///
/// File input content.
///
-internal sealed record ItemContentInputFile : ItemContent
+internal sealed class ItemContentInputFile : ItemContent
{
///
[JsonIgnore]
@@ -380,7 +380,7 @@ internal sealed record ItemContentInputFile : ItemContent
///
/// Text output content.
///
-internal sealed record ItemContentOutputText : ItemContent
+internal sealed class ItemContentOutputText : ItemContent
{
///
[JsonIgnore]
@@ -396,19 +396,19 @@ internal sealed record ItemContentOutputText : ItemContent
/// The annotations.
///
[JsonPropertyName("annotations")]
- public required IList Annotations { get; init; }
+ public required List Annotations { get; init; }
///
/// Log probability information for the output tokens.
///
[JsonPropertyName("logprobs")]
- public IList Logprobs { get; init; } = [];
+ public List Logprobs { get; init; } = [];
}
///
/// Audio output content.
///
-internal sealed record ItemContentOutputAudio : ItemContent
+internal sealed class ItemContentOutputAudio : ItemContent
{
///
[JsonIgnore]
@@ -430,7 +430,7 @@ internal sealed record ItemContentOutputAudio : ItemContent
///
/// Refusal content.
///
-internal sealed record ItemContentRefusal : ItemContent
+internal sealed class ItemContentRefusal : ItemContent
{
///
[JsonIgnore]
@@ -448,7 +448,7 @@ internal sealed record ItemContentRefusal : ItemContent
///
/// A file search tool call item resource.
///
-internal sealed record FileSearchToolCallItemResource : ItemResource
+internal sealed class FileSearchToolCallItemResource : ItemResource
{
///
/// The constant item type identifier for file search call items.
@@ -463,12 +463,24 @@ internal sealed record FileSearchToolCallItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// The queries used to search for files.
+ ///
+ [JsonPropertyName("queries")]
+ public List? Queries { get; init; }
+
+ ///
+ /// The results of the file search tool call.
+ ///
+ [JsonPropertyName("results")]
+ public List? Results { get; init; }
}
///
/// A computer tool call item resource.
///
-internal sealed record ComputerToolCallItemResource : ItemResource
+internal sealed class ComputerToolCallItemResource : ItemResource
{
///
/// The constant item type identifier for computer call items.
@@ -483,12 +495,30 @@ internal sealed record ComputerToolCallItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// An identifier used when responding to the tool call with output.
+ ///
+ [JsonPropertyName("call_id")]
+ public string? CallId { get; init; }
+
+ ///
+ /// The action to perform.
+ ///
+ [JsonPropertyName("action")]
+ public JsonElement? Action { get; init; }
+
+ ///
+ /// The pending safety checks for the computer call.
+ ///
+ [JsonPropertyName("pending_safety_checks")]
+ public List? PendingSafetyChecks { get; init; }
}
///
/// A computer tool call output item resource.
///
-internal sealed record ComputerToolCallOutputItemResource : ItemResource
+internal sealed class ComputerToolCallOutputItemResource : ItemResource
{
///
/// The constant item type identifier for computer call output items.
@@ -503,12 +533,30 @@ internal sealed record ComputerToolCallOutputItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// The ID of the computer tool call that produced the output.
+ ///
+ [JsonPropertyName("call_id")]
+ public string? CallId { get; init; }
+
+ ///
+ /// The safety checks reported by the API that have been acknowledged by the developer.
+ ///
+ [JsonPropertyName("acknowledged_safety_checks")]
+ public List? AcknowledgedSafetyChecks { get; init; }
+
+ ///
+ /// The output of the computer tool call.
+ ///
+ [JsonPropertyName("output")]
+ public JsonElement? Output { get; init; }
}
///
/// A web search tool call item resource.
///
-internal sealed record WebSearchToolCallItemResource : ItemResource
+internal sealed class WebSearchToolCallItemResource : ItemResource
{
///
/// The constant item type identifier for web search call items.
@@ -523,12 +571,18 @@ internal sealed record WebSearchToolCallItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// An object describing the specific action taken in this web search call.
+ ///
+ [JsonPropertyName("action")]
+ public JsonElement? Action { get; init; }
}
///
/// A reasoning item resource.
///
-internal sealed record ReasoningItemResource : ItemResource
+internal sealed class ReasoningItemResource : ItemResource
{
///
/// The constant item type identifier for reasoning items.
@@ -543,12 +597,25 @@ internal sealed record ReasoningItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// The encrypted content of the reasoning item - populated when a response is
+ /// generated with reasoning.encrypted_content in the include parameter.
+ ///
+ [JsonPropertyName("encrypted_content")]
+ public string? EncryptedContent { get; init; }
+
+ ///
+ /// Reasoning text contents.
+ ///
+ [JsonPropertyName("summary")]
+ public List? Summary { get; init; }
}
///
/// An item reference item resource.
///
-internal sealed record ItemReferenceItemResource : ItemResource
+internal sealed class ItemReferenceItemResource : ItemResource
{
///
/// The constant item type identifier for item reference items.
@@ -562,7 +629,7 @@ internal sealed record ItemReferenceItemResource : ItemResource
///
/// An image generation tool call item resource.
///
-internal sealed record ImageGenerationToolCallItemResource : ItemResource
+internal sealed class ImageGenerationToolCallItemResource : ItemResource
{
///
/// The constant item type identifier for image generation call items.
@@ -577,12 +644,18 @@ internal sealed record ImageGenerationToolCallItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// The generated image encoded in base64.
+ ///
+ [JsonPropertyName("result")]
+ public string? Result { get; init; }
}
///
/// A code interpreter tool call item resource.
///
-internal sealed record CodeInterpreterToolCallItemResource : ItemResource
+internal sealed class CodeInterpreterToolCallItemResource : ItemResource
{
///
/// The constant item type identifier for code interpreter call items.
@@ -597,12 +670,31 @@ internal sealed record CodeInterpreterToolCallItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// The ID of the container used to run the code.
+ ///
+ [JsonPropertyName("container_id")]
+ public string? ContainerId { get; init; }
+
+ ///
+ /// The code to run, or null if not available.
+ ///
+ [JsonPropertyName("code")]
+ public string? Code { get; init; }
+
+ ///
+ /// The outputs generated by the code interpreter, such as logs or images.
+ /// Can be null if no outputs are available.
+ ///
+ [JsonPropertyName("outputs")]
+ public List? Outputs { get; init; }
}
///
/// A local shell tool call item resource.
///
-internal sealed record LocalShellToolCallItemResource : ItemResource
+internal sealed class LocalShellToolCallItemResource : ItemResource
{
///
/// The constant item type identifier for local shell call items.
@@ -617,12 +709,24 @@ internal sealed record LocalShellToolCallItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// The unique ID of the local shell tool call generated by the model.
+ ///
+ [JsonPropertyName("call_id")]
+ public string? CallId { get; init; }
+
+ ///
+ /// The action to execute.
+ ///
+ [JsonPropertyName("action")]
+ public JsonElement? Action { get; init; }
}
///
/// A local shell tool call output item resource.
///
-internal sealed record LocalShellToolCallOutputItemResource : ItemResource
+internal sealed class LocalShellToolCallOutputItemResource : ItemResource
{
///
/// The constant item type identifier for local shell call output items.
@@ -637,12 +741,18 @@ internal sealed record LocalShellToolCallOutputItemResource : ItemResource
///
[JsonPropertyName("status")]
public string? Status { get; init; }
+
+ ///
+ /// A JSON string of the output of the local shell tool call.
+ ///
+ [JsonPropertyName("output")]
+ public string? Output { get; init; }
}
///
/// An MCP list tools item resource.
///
-internal sealed record MCPListToolsItemResource : ItemResource
+internal sealed class MCPListToolsItemResource : ItemResource
{
///
/// The constant item type identifier for MCP list tools items.
@@ -651,12 +761,30 @@ internal sealed record MCPListToolsItemResource : ItemResource
///
public override string Type => ItemType;
+
+ ///
+ /// The label of the MCP server.
+ ///
+ [JsonPropertyName("server_label")]
+ public string? ServerLabel { get; init; }
+
+ ///
+ /// The tools available on the server.
+ ///
+ [JsonPropertyName("tools")]
+ public List? Tools { get; init; }
+
+ ///
+ /// Error message if the server could not list tools.
+ ///
+ [JsonPropertyName("error")]
+ public string? Error { get; init; }
}
///
/// An MCP approval request item resource.
///
-internal sealed record MCPApprovalRequestItemResource : ItemResource
+internal sealed class MCPApprovalRequestItemResource : ItemResource
{
///
/// The constant item type identifier for MCP approval request items.
@@ -665,12 +793,30 @@ internal sealed record MCPApprovalRequestItemResource : ItemResource
///
public override string Type => ItemType;
+
+ ///
+ /// The label of the MCP server making the request.
+ ///
+ [JsonPropertyName("server_label")]
+ public string? ServerLabel { get; init; }
+
+ ///
+ /// The name of the tool to run.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ ///
+ /// A JSON string of arguments for the tool.
+ ///
+ [JsonPropertyName("arguments")]
+ public string? Arguments { get; init; }
}
///
/// An MCP approval response item resource.
///
-internal sealed record MCPApprovalResponseItemResource : ItemResource
+internal sealed class MCPApprovalResponseItemResource : ItemResource
{
///
/// The constant item type identifier for MCP approval response items.
@@ -679,12 +825,30 @@ internal sealed record MCPApprovalResponseItemResource : ItemResource
///
public override string Type => ItemType;
+
+ ///
+ /// The ID of the approval request being answered.
+ ///
+ [JsonPropertyName("approval_request_id")]
+ public string? ApprovalRequestId { get; init; }
+
+ ///
+ /// Whether the request was approved.
+ ///
+ [JsonPropertyName("approve")]
+ public bool? Approve { get; init; }
+
+ ///
+ /// Optional reason for the decision.
+ ///
+ [JsonPropertyName("reason")]
+ public string? Reason { get; init; }
}
///
/// An MCP call item resource.
///
-internal sealed record MCPCallItemResource : ItemResource
+internal sealed class MCPCallItemResource : ItemResource
{
///
/// The constant item type identifier for MCP call items.
@@ -693,4 +857,34 @@ internal sealed record MCPCallItemResource : ItemResource
///
public override string Type => ItemType;
+
+ ///
+ /// The label of the MCP server running the tool.
+ ///
+ [JsonPropertyName("server_label")]
+ public string? ServerLabel { get; init; }
+
+ ///
+ /// The name of the tool that was run.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ ///
+ /// A JSON string of the arguments passed to the tool.
+ ///
+ [JsonPropertyName("arguments")]
+ public string? Arguments { get; init; }
+
+ ///
+ /// The output from the tool call.
+ ///
+ [JsonPropertyName("output")]
+ public string? Output { get; init; }
+
+ ///
+ /// The error from the tool call, if any.
+ ///
+ [JsonPropertyName("error")]
+ public string? Error { get; init; }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/PromptReference.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/PromptReference.cs
index d14e94473c..8bf0ee2846 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/PromptReference.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/PromptReference.cs
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
///
/// Reference to a prompt template and its variables.
///
-internal sealed record PromptReference
+internal sealed class PromptReference
{
///
/// The ID of the prompt template to use.
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ReasoningOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ReasoningOptions.cs
index 8be910278a..d34a56c6ee 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ReasoningOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ReasoningOptions.cs
@@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
///
/// Configuration options for reasoning models.
///
-internal sealed record ReasoningOptions
+internal sealed class ReasoningOptions
{
///
/// Constrains effort on reasoning for reasoning models.
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/Response.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/Response.cs
index 260aa2478c..3f9c50e933 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/Response.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/Response.cs
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// The status of a response generation.
///
[JsonConverter(typeof(SnakeCaseEnumConverter))]
-public enum ResponseStatus
+internal enum ResponseStatus
{
///
/// The response has been completed.
@@ -111,7 +111,7 @@ internal sealed record Response
/// The output items (messages) generated in the response.
///
[JsonPropertyName("output")]
- public required IList Output { get; init; }
+ public required List Output { get; init; }
///
/// A system (or developer) message inserted into the model's context.
@@ -135,7 +135,7 @@ internal sealed record Response
/// An array of tools the model may call while generating a response.
///
[JsonPropertyName("tools")]
- public required IList Tools { get; init; }
+ public required List Tools { get; init; }
///
/// How the model should select which tool (or tools) to use when generating a response.
@@ -288,6 +288,9 @@ internal sealed record IncompleteDetails
///
internal sealed record ResponseUsage
{
+ ///
+ /// Gets a zero usage instance.
+ ///
public static ResponseUsage Zero { get; } = new()
{
InputTokens = 0,
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs
index 840adf87b0..d0555a2c00 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs
@@ -21,7 +21,7 @@ internal sealed class ResponseInput : IEquatable
this.Messages = null;
}
- private ResponseInput(IReadOnlyList messages)
+ private ResponseInput(List messages)
{
this.Messages = messages ?? throw new ArgumentNullException(nameof(messages));
this.Text = null;
@@ -35,12 +35,12 @@ internal sealed class ResponseInput : IEquatable
///
/// Creates a ResponseInput from a list of messages.
///
- public static ResponseInput FromMessages(IReadOnlyList messages) => new(messages);
+ public static ResponseInput FromMessages(List messages) => new(messages);
///
/// Creates a ResponseInput from a list of messages.
///
- public static ResponseInput FromMessages(params InputMessage[] messages) => new(messages);
+ public static ResponseInput FromMessages(params InputMessage[] messages) => new(messages.ToList());
///
/// Implicit conversion from string to ResponseInput.
@@ -75,12 +75,13 @@ internal sealed class ResponseInput : IEquatable
///
/// Gets the messages value, or null if this is not a messages input.
///
- public IReadOnlyList? Messages { get; }
+ public List? Messages { get; }
///
/// Gets the input as a list of InputMessage objects.
///
- public IReadOnlyList GetInputMessages()
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Method performs transformation logic")]
+ public List GetInputMessages()
{
if (this.Text is not null)
{
@@ -164,6 +165,7 @@ internal sealed class ResponseInput : IEquatable
///
internal sealed class ResponseInputJsonConverter : JsonConverter
{
+ ///
public override ResponseInput? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Check if it's a string
@@ -176,13 +178,14 @@ internal sealed class ResponseInputJsonConverter : JsonConverter
// Check if it's an array
if (reader.TokenType == JsonTokenType.StartArray)
{
- var messages = JsonSerializer.Deserialize(ref reader, ResponsesJsonContext.Default.ListInputMessage);
+ var messages = JsonSerializer.Deserialize(ref reader, OpenAIHostingJsonContext.Default.ListInputMessage);
return messages is not null ? ResponseInput.FromMessages(messages) : null;
}
throw new JsonException($"Unexpected token type for ResponseInput: {reader.TokenType}");
}
+ ///
public override void Write(Utf8JsonWriter writer, ResponseInput value, JsonSerializerOptions options)
{
if (value.IsText)
@@ -191,7 +194,7 @@ internal sealed class ResponseInputJsonConverter : JsonConverter
}
else if (value.IsMessages)
{
- JsonSerializer.Serialize(writer, value.Messages!, ResponsesJsonContext.Default.IReadOnlyListInputMessage);
+ JsonSerializer.Serialize(writer, value.Messages!, OpenAIHostingJsonContext.Default.ListInputMessage);
}
else
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamOptions.cs
index 3c9a484ddf..93ca5865e5 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamOptions.cs
@@ -7,16 +7,8 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
///
/// Options for streaming responses. Only set this when you set stream: true.
///
-internal sealed record StreamOptions
+internal sealed class StreamOptions
{
- ///
- /// If set, an additional chunk will be streamed before the data: [DONE] message.
- /// The usage field on this chunk shows the token usage statistics for the entire request,
- /// and the choices field will always be an empty array.
- ///
- [JsonPropertyName("include_usage")]
- public bool? IncludeUsage { get; init; }
-
///
/// When true, stream obfuscation will be enabled. Stream obfuscation adds random characters
/// to an obfuscation field on streaming delta events to normalize payload sizes as a mitigation
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs
index 82f80bfa15..6d41e10aff 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs
@@ -16,6 +16,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
[JsonDerivedType(typeof(StreamingResponseCompleted), StreamingResponseCompleted.EventType)]
[JsonDerivedType(typeof(StreamingResponseIncomplete), StreamingResponseIncomplete.EventType)]
[JsonDerivedType(typeof(StreamingResponseFailed), StreamingResponseFailed.EventType)]
+[JsonDerivedType(typeof(StreamingResponseCancelled), StreamingResponseCancelled.EventType)]
[JsonDerivedType(typeof(StreamingOutputItemAdded), StreamingOutputItemAdded.EventType)]
[JsonDerivedType(typeof(StreamingOutputItemDone), StreamingOutputItemDone.EventType)]
[JsonDerivedType(typeof(StreamingContentPartAdded), StreamingContentPartAdded.EventType)]
@@ -26,7 +27,10 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
[JsonDerivedType(typeof(StreamingFunctionCallArgumentsDone), StreamingFunctionCallArgumentsDone.EventType)]
[JsonDerivedType(typeof(StreamingReasoningSummaryTextDelta), StreamingReasoningSummaryTextDelta.EventType)]
[JsonDerivedType(typeof(StreamingReasoningSummaryTextDone), StreamingReasoningSummaryTextDone.EventType)]
-internal abstract record StreamingResponseEvent
+[JsonDerivedType(typeof(StreamingWorkflowEventComplete), StreamingWorkflowEventComplete.EventType)]
+[JsonDerivedType(typeof(StreamingFunctionApprovalRequested), StreamingFunctionApprovalRequested.EventType)]
+[JsonDerivedType(typeof(StreamingFunctionApprovalResponded), StreamingFunctionApprovalResponded.EventType)]
+internal abstract class StreamingResponseEvent
{
///
/// Gets the type identifier for the streaming response event.
@@ -43,11 +47,22 @@ internal abstract record StreamingResponseEvent
public int SequenceNumber { get; init; }
}
+///
+/// Denotes an instance which contains an update to the instance.
+///
+internal interface IStreamingResponseEventWithResponse
+{
+ ///
+ /// Gets the response object associated with this streaming event.
+ ///
+ Response Response { get; }
+}
+
///
/// 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.
///
-internal sealed record StreamingResponseCreated : StreamingResponseEvent
+internal sealed class StreamingResponseCreated : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
///
/// The constant event type identifier for response created events.
@@ -69,7 +84,7 @@ internal sealed record StreamingResponseCreated : StreamingResponseEvent
///
/// Represents a streaming response event indicating that the response is in progress.
///
-internal sealed record StreamingResponseInProgress : StreamingResponseEvent
+internal sealed class StreamingResponseInProgress : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
///
/// The constant event type identifier for response in progress events.
@@ -91,7 +106,7 @@ internal sealed record StreamingResponseInProgress : StreamingResponseEvent
/// Represents a streaming response event indicating that the response has been completed.
/// This is typically the last event sent in a streaming response sequence.
///
-internal sealed record StreamingResponseCompleted : StreamingResponseEvent
+internal sealed class StreamingResponseCompleted : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
///
/// The constant event type identifier for response completed events.
@@ -113,7 +128,7 @@ internal sealed record StreamingResponseCompleted : StreamingResponseEvent
///
/// Represents a streaming response event indicating that the response finished as incomplete.
///
-internal sealed record StreamingResponseIncomplete : StreamingResponseEvent
+internal sealed class StreamingResponseIncomplete : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
///
/// The constant event type identifier for response incomplete events.
@@ -134,7 +149,7 @@ internal sealed record StreamingResponseIncomplete : StreamingResponseEvent
///
/// Represents a streaming response event indicating that the response has failed.
///
-internal sealed record StreamingResponseFailed : StreamingResponseEvent
+internal sealed class StreamingResponseFailed : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
///
/// The constant event type identifier for response failed events.
@@ -152,11 +167,33 @@ internal sealed record StreamingResponseFailed : StreamingResponseEvent
public required Response Response { get; init; }
}
+///
+/// Represents a streaming response event indicating that the response has been cancelled.
+/// Only responses created with background=true can be cancelled.
+///
+internal sealed class StreamingResponseCancelled : StreamingResponseEvent, IStreamingResponseEventWithResponse
+{
+ ///
+ /// The constant event type identifier for response cancelled events.
+ ///
+ public const string EventType = "response.cancelled";
+
+ ///
+ [JsonIgnore]
+ public override string Type => EventType;
+
+ ///
+ /// Gets or sets the cancelled response object.
+ ///
+ [JsonPropertyName("response")]
+ public required Response Response { get; init; }
+}
+
///
/// 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.
///
-internal sealed record StreamingOutputItemAdded : StreamingResponseEvent
+internal sealed class StreamingOutputItemAdded : StreamingResponseEvent
{
///
/// The constant event type identifier for output item added events.
@@ -186,7 +223,7 @@ internal sealed record StreamingOutputItemAdded : StreamingResponseEvent
/// 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.
///
-internal sealed record StreamingOutputItemDone : StreamingResponseEvent
+internal sealed class StreamingOutputItemDone : StreamingResponseEvent
{
///
/// The constant event type identifier for output item done events.
@@ -215,7 +252,7 @@ internal sealed record StreamingOutputItemDone : StreamingResponseEvent
///
/// Represents a streaming response event indicating that a new content part has been added to an output item.
///
-internal sealed record StreamingContentPartAdded : StreamingResponseEvent
+internal sealed class StreamingContentPartAdded : StreamingResponseEvent
{
///
/// The constant event type identifier for content part added events.
@@ -254,7 +291,7 @@ internal sealed record StreamingContentPartAdded : StreamingResponseEvent
///
/// Represents a streaming response event indicating that a content part has been completed.
///
-internal sealed record StreamingContentPartDone : StreamingResponseEvent
+internal sealed class StreamingContentPartDone : StreamingResponseEvent
{
///
/// The constant event type identifier for content part done events.
@@ -293,7 +330,7 @@ internal sealed record StreamingContentPartDone : StreamingResponseEvent
///
/// Represents a streaming response event containing a text delta (incremental text chunk).
///
-internal sealed record StreamingOutputTextDelta : StreamingResponseEvent
+internal sealed class StreamingOutputTextDelta : StreamingResponseEvent
{
///
/// The constant event type identifier for output text delta events.
@@ -332,13 +369,13 @@ internal sealed record StreamingOutputTextDelta : StreamingResponseEvent
/// Gets or sets the log probability information for the output tokens.
///
[JsonPropertyName("logprobs")]
- public IList Logprobs { get; init; } = [];
+ public List Logprobs { get; init; } = [];
}
///
/// Represents a streaming response event indicating that output text has been completed.
///
-internal sealed record StreamingOutputTextDone : StreamingResponseEvent
+internal sealed class StreamingOutputTextDone : StreamingResponseEvent
{
///
/// The constant event type identifier for output text done events.
@@ -377,7 +414,7 @@ internal sealed record StreamingOutputTextDone : StreamingResponseEvent
///
/// Represents a streaming response event containing a function call arguments delta.
///
-internal sealed record StreamingFunctionCallArgumentsDelta : StreamingResponseEvent
+internal sealed class StreamingFunctionCallArgumentsDelta : StreamingResponseEvent
{
///
/// The constant event type identifier for function call arguments delta events.
@@ -410,7 +447,7 @@ internal sealed record StreamingFunctionCallArgumentsDelta : StreamingResponseEv
///
/// Represents a streaming response event indicating that function call arguments are complete.
///
-internal sealed record StreamingFunctionCallArgumentsDone : StreamingResponseEvent
+internal sealed class StreamingFunctionCallArgumentsDone : StreamingResponseEvent
{
///
/// The constant event type identifier for function call arguments done events.
@@ -443,7 +480,7 @@ internal sealed record StreamingFunctionCallArgumentsDone : StreamingResponseEve
///
/// Represents a streaming response event containing a reasoning summary text delta (incremental text chunk).
///
-internal sealed record StreamingReasoningSummaryTextDelta : StreamingResponseEvent
+internal sealed class StreamingReasoningSummaryTextDelta : StreamingResponseEvent
{
///
/// The constant event type identifier for reasoning summary text delta events.
@@ -482,7 +519,7 @@ internal sealed record StreamingReasoningSummaryTextDelta : StreamingResponseEve
///
/// Represents a streaming response event indicating that reasoning summary text has been completed.
///
-internal sealed record StreamingReasoningSummaryTextDone : StreamingResponseEvent
+internal sealed class StreamingReasoningSummaryTextDone : StreamingResponseEvent
{
///
/// The constant event type identifier for reasoning summary text done events.
@@ -517,3 +554,148 @@ internal sealed record StreamingReasoningSummaryTextDone : StreamingResponseEven
[JsonPropertyName("text")]
public required string Text { get; init; }
}
+
+///
+/// Represents a streaming response event containing a workflow event.
+/// This event is sent during workflow execution to provide observability into workflow steps,
+/// executor invocations, errors, and other workflow lifecycle events.
+///
+internal sealed class StreamingWorkflowEventComplete : StreamingResponseEvent
+{
+ ///
+ /// The constant event type identifier for workflow event events.
+ ///
+ public const string EventType = "response.workflow_event.complete";
+
+ ///
+ [JsonIgnore]
+ public override string Type => EventType;
+
+ ///
+ /// Gets or sets the index of the output in the response.
+ ///
+ [JsonPropertyName("output_index")]
+ public int OutputIndex { get; set; }
+
+ ///
+ /// Gets or sets the workflow event data containing event type, executor ID, and event-specific data.
+ ///
+ [JsonPropertyName("data")]
+ public JsonElement? Data { get; set; }
+
+ ///
+ /// Gets or sets the executor ID if this is an executor-scoped event.
+ ///
+ [JsonPropertyName("executor_id")]
+ public string? ExecutorId { get; set; }
+
+ ///
+ /// Gets or sets the item ID for tracking purposes.
+ ///
+ [JsonPropertyName("item_id")]
+ public string? ItemId { get; set; }
+}
+
+///
+/// Represents a streaming response event indicating a function approval has been requested.
+/// This is a non-standard DevUI extension for human-in-the-loop scenarios.
+///
+internal sealed class StreamingFunctionApprovalRequested : StreamingResponseEvent
+{
+ ///
+ /// The constant event type identifier for function approval requested events.
+ ///
+ public const string EventType = "response.function_approval.requested";
+
+ ///
+ [JsonIgnore]
+ public override string Type => EventType;
+
+ ///
+ /// Gets or sets the unique identifier for the approval request.
+ ///
+ [JsonPropertyName("request_id")]
+ public required string RequestId { get; init; }
+
+ ///
+ /// Gets or sets the function call that requires approval.
+ ///
+ [JsonPropertyName("function_call")]
+ public required FunctionCallInfo FunctionCall { get; init; }
+
+ ///
+ /// Gets or sets the item ID for tracking purposes.
+ ///
+ [JsonPropertyName("item_id")]
+ public required string ItemId { get; init; }
+
+ ///
+ /// Gets or sets the output index.
+ ///
+ [JsonPropertyName("output_index")]
+ public int OutputIndex { get; init; }
+}
+
+///
+/// Represents a streaming response event indicating a function approval has been responded to.
+/// This is a non-standard DevUI extension for human-in-the-loop scenarios.
+///
+internal sealed class StreamingFunctionApprovalResponded : StreamingResponseEvent
+{
+ ///
+ /// The constant event type identifier for function approval responded events.
+ ///
+ public const string EventType = "response.function_approval.responded";
+
+ ///
+ [JsonIgnore]
+ public override string Type => EventType;
+
+ ///
+ /// Gets or sets the unique identifier of the approval request being responded to.
+ ///
+ [JsonPropertyName("request_id")]
+ public required string RequestId { get; init; }
+
+ ///
+ /// Gets or sets a value indicating whether the function call was approved.
+ ///
+ [JsonPropertyName("approved")]
+ public bool Approved { get; init; }
+
+ ///
+ /// Gets or sets the item ID for tracking purposes.
+ ///
+ [JsonPropertyName("item_id")]
+ public required string ItemId { get; init; }
+
+ ///
+ /// Gets or sets the output index.
+ ///
+ [JsonPropertyName("output_index")]
+ public int OutputIndex { get; init; }
+}
+
+///
+/// Represents function call information for approval events.
+///
+internal sealed class FunctionCallInfo
+{
+ ///
+ /// Gets or sets the function call ID.
+ ///
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ ///
+ /// Gets or sets the function name.
+ ///
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ ///
+ /// Gets or sets the function arguments.
+ ///
+ [JsonPropertyName("arguments")]
+ public required JsonElement Arguments { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/TextConfiguration.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/TextConfiguration.cs
index dc590030e6..6a4e98651d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/TextConfiguration.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/TextConfiguration.cs
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
-using System.Collections.Generic;
+using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
///
/// Configuration options for a text response from the model.
///
-internal sealed record TextConfiguration
+internal sealed class TextConfiguration
{
///
/// The format configuration for the text response.
@@ -34,7 +34,7 @@ internal sealed record TextConfiguration
[JsonDerivedType(typeof(ResponseTextFormatConfigurationText), "text")]
[JsonDerivedType(typeof(ResponseTextFormatConfigurationJsonObject), "json_object")]
[JsonDerivedType(typeof(ResponseTextFormatConfigurationJsonSchema), "json_schema")]
-internal abstract record ResponseTextFormatConfiguration
+internal abstract class ResponseTextFormatConfiguration
{
///
/// The type of response format.
@@ -46,7 +46,7 @@ internal abstract record ResponseTextFormatConfiguration
///
/// Plain text response format configuration.
///
-internal sealed record ResponseTextFormatConfigurationText : ResponseTextFormatConfiguration
+internal sealed class ResponseTextFormatConfigurationText : ResponseTextFormatConfiguration
{
///
/// Gets the type of response format. Always "text".
@@ -59,7 +59,7 @@ internal sealed record ResponseTextFormatConfigurationText : ResponseTextFormatC
/// JSON object response format configuration.
/// Ensures the message the model generates is valid JSON.
///
-internal sealed record ResponseTextFormatConfigurationJsonObject : ResponseTextFormatConfiguration
+internal sealed class ResponseTextFormatConfigurationJsonObject : ResponseTextFormatConfiguration
{
///
/// Gets the type of response format. Always "json_object".
@@ -71,7 +71,7 @@ internal sealed record ResponseTextFormatConfigurationJsonObject : ResponseTextF
///
/// JSON schema response format configuration with structured output schema.
///
-internal sealed record ResponseTextFormatConfigurationJsonSchema : ResponseTextFormatConfiguration
+internal sealed class ResponseTextFormatConfigurationJsonSchema : ResponseTextFormatConfiguration
{
///
/// Gets the type of response format. Always "json_schema".
@@ -97,7 +97,7 @@ internal sealed record ResponseTextFormatConfigurationJsonSchema : ResponseTextF
/// The JSON schema for structured outputs.
///
[JsonPropertyName("schema")]
- public required Dictionary Schema { get; init; }
+ public required JsonElement Schema { get; init; }
///
/// Whether to enable strict schema adherence when generating the output.
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/WorkflowEventData.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/WorkflowEventData.cs
new file mode 100644
index 0000000000..cc7f44cda6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/WorkflowEventData.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+
+///
+/// Represents workflow event data for serialization.
+///
+internal sealed class WorkflowEventData
+{
+ ///
+ /// The type of the workflow event.
+ ///
+ [JsonPropertyName("event_type")]
+ public required string EventType { get; init; }
+
+ ///
+ /// The event data payload.
+ ///
+ [JsonPropertyName("data")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public JsonElement? Data { get; init; }
+
+ ///
+ /// The executor ID, if this is an executor event.
+ ///
+ [JsonPropertyName("executor_id")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ExecutorId { get; init; }
+
+ ///
+ /// The timestamp when the event occurred.
+ ///
+ [JsonPropertyName("timestamp")]
+ public required string Timestamp { get; init; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs
new file mode 100644
index 0000000000..31f61e967e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs
@@ -0,0 +1,227 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.Models;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
+
+///
+/// Handles route requests for OpenAI Responses API endpoints.
+///
+internal sealed class ResponsesHttpHandler
+{
+ private readonly IResponsesService _responsesService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The responses service.
+ public ResponsesHttpHandler(IResponsesService responsesService)
+ {
+ this._responsesService = responsesService ?? throw new ArgumentNullException(nameof(responsesService));
+ }
+
+ ///
+ /// Creates a model response for the given input.
+ ///
+ public async Task CreateResponseAsync(
+ [FromBody] CreateResponse request,
+ [FromQuery] bool? stream,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Handle streaming vs non-streaming
+ bool shouldStream = stream ?? request.Stream ?? false;
+
+ if (shouldStream)
+ {
+ var streamingResponse = this._responsesService.CreateResponseStreamingAsync(
+ request,
+ cancellationToken: cancellationToken);
+
+ return new SseJsonResult(
+ streamingResponse,
+ static evt => evt.Type,
+ OpenAIHostingJsonContext.Default.StreamingResponseEvent);
+ }
+
+ var response = await this._responsesService.CreateResponseAsync(
+ request,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(response);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("Mutually exclusive"))
+ {
+ // Return OpenAI-style error for mutual exclusivity violations
+ return Results.BadRequest(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = ex.Message,
+ Type = "invalid_request_error",
+ Code = "mutually_exclusive_parameters"
+ }
+ });
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("not found") || ex.Message.Contains("does not exist"))
+ {
+ // Return OpenAI-style error for not found errors
+ return Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = ex.Message,
+ Type = "invalid_request_error"
+ }
+ });
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("No 'agent.name' or 'model' specified"))
+ {
+ // Return OpenAI-style error for missing required parameters
+ return Results.BadRequest(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = ex.Message,
+ Type = "invalid_request_error",
+ Code = "missing_required_parameter"
+ }
+ });
+ }
+ }
+
+ ///
+ /// Retrieves a response by ID.
+ ///
+ public async Task GetResponseAsync(
+ string responseId,
+ [FromQuery] string[]? include,
+ [FromQuery] bool? stream,
+ [FromQuery] int? starting_after,
+ CancellationToken cancellationToken)
+ {
+ // If streaming is requested, return SSE stream
+ if (stream == true)
+ {
+ var streamingResponse = this._responsesService.GetResponseStreamingAsync(
+ responseId,
+ startingAfter: starting_after,
+ cancellationToken: cancellationToken);
+
+ return new SseJsonResult(
+ streamingResponse,
+ static evt => evt.Type,
+ OpenAIHostingJsonContext.Default.StreamingResponseEvent);
+ }
+
+ // Non-streaming: return the response object
+ var response = await this._responsesService.GetResponseAsync(responseId, cancellationToken).ConfigureAwait(false);
+ return response is not null
+ ? Results.Ok(response)
+ : Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Response '{responseId}' not found.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ ///
+ /// Cancels an in-progress response.
+ ///
+ public async Task CancelResponseAsync(
+ string responseId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var response = await this._responsesService.CancelResponseAsync(responseId, cancellationToken).ConfigureAwait(false);
+ return Results.Ok(response);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.BadRequest(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = ex.Message,
+ Type = "invalid_request_error"
+ }
+ });
+ }
+ }
+
+ ///
+ /// Deletes a response.
+ ///
+ public async Task DeleteResponseAsync(
+ string responseId,
+ CancellationToken cancellationToken)
+ {
+ var deleted = await this._responsesService.DeleteResponseAsync(responseId, cancellationToken).ConfigureAwait(false);
+ return deleted
+ ? Results.Ok(new DeleteResponse { Id = responseId, Object = "response", Deleted = true })
+ : Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = $"Response '{responseId}' not found.",
+ Type = "invalid_request_error"
+ }
+ });
+ }
+
+ ///
+ /// Lists the input items for a response.
+ ///
+ public async Task ListResponseInputItemsAsync(
+ string responseId,
+ [FromQuery] int? limit,
+ [FromQuery] string? order,
+ [FromQuery] string? after,
+ [FromQuery] string? before,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Convert string order to SortOrder enum
+ SortOrder? sortOrder = order switch
+ {
+ string s when s.Equals("asc", StringComparison.OrdinalIgnoreCase) => SortOrder.Ascending,
+ string s when s.Equals("desc", StringComparison.OrdinalIgnoreCase) => SortOrder.Descending,
+ null => null,
+ _ => throw new InvalidOperationException($"Invalid order value: {order}. Must be 'asc' or 'desc'.")
+ };
+
+ var result = await this._responsesService.ListResponseInputItemsAsync(
+ responseId,
+ limit,
+ sortOrder,
+ after,
+ before,
+ cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.NotFound(new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = ex.Message,
+ Type = "invalid_request_error"
+ }
+ });
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesJsonSerializerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesJsonSerializerOptions.cs
deleted file mode 100644
index 5f014466fc..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesJsonSerializerOptions.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json;
-
-namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
-
-///
-/// Extension methods for JSON serialization.
-///
-internal static class ResponsesJsonSerializerOptions
-{
- ///
- /// Gets the default JSON serializer options.
- ///
- public static JsonSerializerOptions Default { get; } = Create();
-
- private static JsonSerializerOptions Create()
- {
- JsonSerializerOptions options = new(ResponsesJsonContext.Default.Options);
- options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
- options.MakeReadOnly();
- return options;
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/AudioContentEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/AudioContentEventGenerator.cs
index 0d80f93154..446b0401ac 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/AudioContentEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/AudioContentEventGenerator.cs
@@ -16,27 +16,18 @@ internal sealed class AudioContentEventGenerator(
SequenceNumber seq,
int outputIndex) : StreamingEventGenerator
{
- private bool _isCompleted;
-
public override bool IsSupported(AIContent content) =>
content is DataContent dataContent && dataContent.HasTopLevelMediaType("audio");
public override IEnumerable ProcessContent(AIContent content)
{
- if (this._isCompleted)
- {
- throw new InvalidOperationException("Cannot process content after the generator has been completed.");
- }
-
if (content is not DataContent audioData || !audioData.HasTopLevelMediaType("audio"))
{
throw new InvalidOperationException("AudioContentEventGenerator only supports audio DataContent.");
}
var itemId = idGenerator.GenerateMessageId();
- var itemContent = ItemContentConverter.ToItemContent(content) as ItemContentInputAudio;
-
- if (itemContent == null)
+ if (ItemContentConverter.ToItemContent(content) is not ItemContentInputAudio itemContent)
{
throw new InvalidOperationException("Failed to convert audio content to ItemContentInputAudio.");
}
@@ -79,13 +70,7 @@ internal sealed class AudioContentEventGenerator(
OutputIndex = outputIndex,
Item = item
};
-
- this._isCompleted = true;
}
- public override IEnumerable Complete()
- {
- this._isCompleted = true;
- return [];
- }
+ public override IEnumerable Complete() => [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ErrorContentEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ErrorContentEventGenerator.cs
index 8320b1f977..6380cb8ba7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ErrorContentEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ErrorContentEventGenerator.cs
@@ -16,26 +16,17 @@ internal sealed class ErrorContentEventGenerator(
SequenceNumber seq,
int outputIndex) : StreamingEventGenerator
{
- private bool _isCompleted;
-
public override bool IsSupported(AIContent content) => content is ErrorContent;
public override IEnumerable ProcessContent(AIContent content)
{
- if (this._isCompleted)
- {
- throw new InvalidOperationException("Cannot process content after the generator has been completed.");
- }
-
if (content is not ErrorContent)
{
throw new InvalidOperationException("ErrorContentEventGenerator only supports ErrorContent.");
}
var itemId = idGenerator.GenerateMessageId();
- var itemContent = ItemContentConverter.ToItemContent(content) as ItemContentRefusal;
-
- if (itemContent == null)
+ if (ItemContentConverter.ToItemContent(content) is not ItemContentRefusal itemContent)
{
throw new InvalidOperationException("Failed to convert error content to ItemContentRefusal.");
}
@@ -78,13 +69,7 @@ internal sealed class ErrorContentEventGenerator(
OutputIndex = outputIndex,
Item = item
};
-
- this._isCompleted = true;
}
- public override IEnumerable Complete()
- {
- this._isCompleted = true;
- return [];
- }
+ public override IEnumerable Complete() => [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FileContentEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FileContentEventGenerator.cs
index bf60c2f1cf..5fe7333ac3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FileContentEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FileContentEventGenerator.cs
@@ -16,8 +16,6 @@ internal sealed class FileContentEventGenerator(
SequenceNumber seq,
int outputIndex) : StreamingEventGenerator
{
- private bool _isCompleted;
-
public override bool IsSupported(AIContent content) =>
content is DataContent dataContent &&
!dataContent.HasTopLevelMediaType("image") &&
@@ -25,11 +23,6 @@ internal sealed class FileContentEventGenerator(
public override IEnumerable ProcessContent(AIContent content)
{
- if (this._isCompleted)
- {
- throw new InvalidOperationException("Cannot process content after the generator has been completed.");
- }
-
if (content is not DataContent fileData ||
fileData.HasTopLevelMediaType("image") ||
fileData.HasTopLevelMediaType("audio"))
@@ -38,9 +31,7 @@ internal sealed class FileContentEventGenerator(
}
var itemId = idGenerator.GenerateMessageId();
- var itemContent = ItemContentConverter.ToItemContent(content) as ItemContentInputFile;
-
- if (itemContent == null)
+ if (ItemContentConverter.ToItemContent(content) is not ItemContentInputFile itemContent)
{
throw new InvalidOperationException("Failed to convert file content to ItemContentInputFile.");
}
@@ -83,13 +74,7 @@ internal sealed class FileContentEventGenerator(
OutputIndex = outputIndex,
Item = item
};
-
- this._isCompleted = true;
}
- public override IEnumerable Complete()
- {
- this._isCompleted = true;
- return [];
- }
+ public override IEnumerable Complete() => [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs
new file mode 100644
index 0000000000..4e565b0784
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+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.Streaming;
+
+///
+/// A generator for streaming events from function approval request content.
+/// This is a non-standard DevUI extension for human-in-the-loop scenarios.
+///
+internal sealed class FunctionApprovalRequestEventGenerator(
+ IdGenerator idGenerator,
+ SequenceNumber seq,
+ int outputIndex,
+ JsonSerializerOptions jsonSerializerOptions) : StreamingEventGenerator
+{
+ public override bool IsSupported(AIContent content) => content is FunctionApprovalRequestContent;
+
+ public override IEnumerable ProcessContent(AIContent content)
+ {
+ if (content is not FunctionApprovalRequestContent approvalRequest)
+ {
+ throw new InvalidOperationException("FunctionApprovalRequestEventGenerator only supports FunctionApprovalRequestContent.");
+ }
+
+ yield return new StreamingFunctionApprovalRequested
+ {
+ SequenceNumber = seq.Increment(),
+ OutputIndex = outputIndex,
+ RequestId = approvalRequest.Id,
+ ItemId = idGenerator.GenerateMessageId(),
+ FunctionCall = new FunctionCallInfo
+ {
+ Id = approvalRequest.FunctionCall.CallId,
+ Name = approvalRequest.FunctionCall.Name,
+ Arguments = JsonSerializer.SerializeToElement(
+ approvalRequest.FunctionCall.Arguments,
+ jsonSerializerOptions.GetTypeInfo(typeof(IDictionary)))
+ }
+ };
+ }
+
+ public override IEnumerable Complete() => [];
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalResponseEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalResponseEventGenerator.cs
new file mode 100644
index 0000000000..ab4af8f408
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalResponseEventGenerator.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Streaming;
+
+///
+/// A generator for streaming events from function approval response content.
+/// This is a non-standard DevUI extension for human-in-the-loop scenarios.
+///
+internal sealed class FunctionApprovalResponseEventGenerator(
+ IdGenerator idGenerator,
+ SequenceNumber seq,
+ int outputIndex) : StreamingEventGenerator
+{
+ public override bool IsSupported(AIContent content) => content is FunctionApprovalResponseContent;
+
+ public override IEnumerable ProcessContent(AIContent content)
+ {
+ if (content is not FunctionApprovalResponseContent approvalResponse)
+ {
+ throw new InvalidOperationException("FunctionApprovalResponseEventGenerator only supports FunctionApprovalResponseContent.");
+ }
+
+ yield return new StreamingFunctionApprovalResponded
+ {
+ SequenceNumber = seq.Increment(),
+ OutputIndex = outputIndex,
+ RequestId = approvalResponse.Id,
+ Approved = approvalResponse.Approved,
+ ItemId = idGenerator.GenerateMessageId()
+ };
+ }
+
+ public override IEnumerable Complete() => [];
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionCallEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionCallEventGenerator.cs
index 74f5ffe4ae..c0b0aba54d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionCallEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionCallEventGenerator.cs
@@ -17,17 +17,10 @@ internal sealed class FunctionCallEventGenerator(
int outputIndex,
JsonSerializerOptions jsonSerializerOptions) : StreamingEventGenerator
{
- private bool _isCompleted;
-
public override bool IsSupported(AIContent content) => content is FunctionCallContent;
public override IEnumerable ProcessContent(AIContent content)
{
- if (this._isCompleted)
- {
- throw new InvalidOperationException("Cannot process content after the generator has been completed.");
- }
-
if (content is not FunctionCallContent functionCallContent)
{
throw new InvalidOperationException("FunctionCallEventGenerator only supports FunctionCallContent.");
@@ -63,13 +56,7 @@ internal sealed class FunctionCallEventGenerator(
OutputIndex = outputIndex,
Item = item
};
-
- this._isCompleted = true;
}
- public override IEnumerable Complete()
- {
- this._isCompleted = true;
- return [];
- }
+ public override IEnumerable Complete() => [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionResultEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionResultEventGenerator.cs
index 1c7810a825..116eb716e1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionResultEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionResultEventGenerator.cs
@@ -15,17 +15,10 @@ internal sealed class FunctionResultEventGenerator(
SequenceNumber seq,
int outputIndex) : StreamingEventGenerator
{
- private bool _isCompleted;
-
public override bool IsSupported(AIContent content) => content is FunctionResultContent;
public override IEnumerable ProcessContent(AIContent content)
{
- if (this._isCompleted)
- {
- throw new InvalidOperationException("Cannot process content after the generator has been completed.");
- }
-
if (content is not FunctionResultContent functionResultContent)
{
throw new InvalidOperationException("FunctionResultEventGenerator only supports FunctionResultContent.");
@@ -45,13 +38,7 @@ internal sealed class FunctionResultEventGenerator(
OutputIndex = outputIndex,
Item = item
};
-
- this._isCompleted = true;
}
- public override IEnumerable Complete()
- {
- this._isCompleted = true;
- return [];
- }
+ public override IEnumerable Complete() => [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/HostedFileContentEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/HostedFileContentEventGenerator.cs
index 5846858aa2..a8beefe211 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/HostedFileContentEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/HostedFileContentEventGenerator.cs
@@ -16,26 +16,17 @@ internal sealed class HostedFileContentEventGenerator(
SequenceNumber seq,
int outputIndex) : StreamingEventGenerator
{
- private bool _isCompleted;
-
public override bool IsSupported(AIContent content) => content is HostedFileContent;
public override IEnumerable ProcessContent(AIContent content)
{
- if (this._isCompleted)
- {
- throw new InvalidOperationException("Cannot process content after the generator has been completed.");
- }
-
if (content is not HostedFileContent)
{
throw new InvalidOperationException("HostedFileContentEventGenerator only supports HostedFileContent.");
}
var itemId = idGenerator.GenerateMessageId();
- var itemContent = ItemContentConverter.ToItemContent(content) as ItemContentInputFile;
-
- if (itemContent == null)
+ if (ItemContentConverter.ToItemContent(content) is not ItemContentInputFile itemContent)
{
throw new InvalidOperationException("Failed to convert hosted file content to ItemContentInputFile.");
}
@@ -78,13 +69,7 @@ internal sealed class HostedFileContentEventGenerator(
OutputIndex = outputIndex,
Item = item
};
-
- this._isCompleted = true;
}
- public override IEnumerable Complete()
- {
- this._isCompleted = true;
- return [];
- }
+ public override IEnumerable Complete() => [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ImageContentEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ImageContentEventGenerator.cs
index 0b80abbfd2..0642043f3d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ImageContentEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/ImageContentEventGenerator.cs
@@ -16,22 +16,13 @@ internal sealed class ImageContentEventGenerator(
SequenceNumber seq,
int outputIndex) : StreamingEventGenerator
{
- private bool _isCompleted;
-
public override bool IsSupported(AIContent content) =>
- content is UriContent uriContent && uriContent.HasTopLevelMediaType("image") ||
- content is DataContent dataContent && dataContent.HasTopLevelMediaType("image");
+ (content is UriContent uriContent && uriContent.HasTopLevelMediaType("image")) ||
+ (content is DataContent dataContent && dataContent.HasTopLevelMediaType("image"));
public override IEnumerable ProcessContent(AIContent content)
{
- if (this._isCompleted)
- {
- throw new InvalidOperationException("Cannot process content after the generator has been completed.");
- }
-
- ItemContentInputImage? itemContent = ItemContentConverter.ToItemContent(content) as ItemContentInputImage;
-
- if (itemContent == null)
+ if (ItemContentConverter.ToItemContent(content) is not ItemContentInputImage itemContent)
{
throw new InvalidOperationException("ImageContentEventGenerator only supports image UriContent and DataContent.");
}
@@ -76,13 +67,7 @@ internal sealed class ImageContentEventGenerator(
OutputIndex = outputIndex,
Item = item
};
-
- this._isCompleted = true;
}
- public override IEnumerable Complete()
- {
- this._isCompleted = true;
- return [];
- }
+ public override IEnumerable Complete() => [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/TextReasoningContentEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/TextReasoningContentEventGenerator.cs
index 5be8d73aa9..3004b00085 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/TextReasoningContentEventGenerator.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/TextReasoningContentEventGenerator.cs
@@ -18,7 +18,7 @@ internal sealed class TextReasoningContentEventGenerator(
int outputIndex) : StreamingEventGenerator
{
private State _currentState = State.Initial;
- private readonly string _itemId = idGenerator.GenerateMessageId();
+ private readonly string _itemId = idGenerator.GenerateReasoningId();
private readonly StringBuilder _text = new();
private const int SummaryIndex = 0; // Summary index for reasoning summary text
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs
index d4ea17e912..54e8bd7ba3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs
@@ -2,9 +2,12 @@
using System;
using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
+using Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.AspNetCore.Http.Json;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
@@ -29,6 +32,7 @@ public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions
///
/// Adds support for exposing instances via OpenAI Responses.
+ /// Uses the in-memory responses service implementation.
///
/// The to configure.
/// The for method chaining.
@@ -36,8 +40,39 @@ public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
- services.Configure(options => options.SerializerOptions.TypeInfoResolverChain.Add(ResponsesJsonSerializerOptions.Default.TypeInfoResolver!));
+ services.Configure(options
+ => options.SerializerOptions.TypeInfoResolverChain.Add(
+ OpenAIHostingJsonContext.Default.Options.TypeInfoResolver!));
+
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp =>
+ {
+ var executor = sp.GetRequiredService();
+ var options = sp.GetRequiredService();
+ var conversationStorage = sp.GetService();
+ return new InMemoryResponsesService(executor, options, conversationStorage);
+ });
+ services.TryAddSingleton();
return services;
}
+
+ ///
+ /// Adds in-memory conversation storage and indexing services to the service collection.
+ /// This is suitable only for development and testing scenarios.
+ ///
+ /// The service collection to add services to.
+ /// The service collection for chaining.
+ public static IServiceCollection AddOpenAIConversations(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ // Register storage options
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ return services;
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/SseJsonResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/SseJsonResult.cs
new file mode 100644
index 0000000000..2edb2f0027
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/SseJsonResult.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Net.ServerSentEvents;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI;
+
+///
+/// IResult implementation for streaming JSON data using Server-Sent Events (SSE).
+///
+/// The type of items to stream.
+internal sealed class SseJsonResult : IResult
+{
+ private readonly IAsyncEnumerable _events;
+ private readonly JsonTypeInfo