.NET: Improve fidelity of OpenAI Responses server and add Conversations (#1907)

* Improve fidelity of OpenAI Responses server and add Conversations

* Merge

* nit

* Undo prior change

* Undo prior change

* Review feedback

* Review feedback

* Fix test

* Use simpler JsonDocument approach for polymorphic deserialization

* More review feedback

* dotnet format
This commit is contained in:
Reuben Bond
2025-11-05 10:46:19 -08:00
committed by GitHub
Unverified
parent e2282ebe42
commit 33f84f9ed2
135 changed files with 11718 additions and 1243 deletions
@@ -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))
{
@@ -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,
@@ -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)
@@ -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;
/// <summary>
/// Handles route requests for OpenAI Conversations API endpoints.
/// </summary>
internal sealed class ConversationsHttpHandler
{
private readonly IConversationStorage _storage;
private readonly IAgentConversationIndex? _conversationIndex;
/// <summary>
/// Initializes a new instance of the <see cref="ConversationsHttpHandler"/> class.
/// </summary>
/// <param name="storage">The conversation storage service.</param>
/// <param name="conversationIndex">Optional conversation index service.</param>
public ConversationsHttpHandler(IConversationStorage storage, IAgentConversationIndex? conversationIndex)
{
this._storage = storage ?? throw new ArgumentNullException(nameof(storage));
this._conversationIndex = conversationIndex;
}
/// <summary>
/// Lists conversations by agent ID.
/// </summary>
public async Task<IResult> 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<Conversation>
{
Data = [],
HasMore = false
});
}
var conversationIdsResponse = await this._conversationIndex.GetConversationIdsAsync(agent_id, cancellationToken).ConfigureAwait(false);
// Fetch full conversation objects
var conversations = new List<Conversation>();
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<Conversation>
{
Data = conversations,
HasMore = false
});
}
/// <summary>
/// Creates a new conversation.
/// </summary>
public async Task<IResult> CreateConversationAsync(
[FromBody] CreateConversationRequest request,
CancellationToken cancellationToken)
{
Dictionary<string, string> 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<ItemResource> 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);
}
/// <summary>
/// Retrieves a conversation by ID.
/// </summary>
public async Task<IResult> 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"
}
});
}
/// <summary>
/// Updates a conversation's metadata.
/// </summary>
public async Task<IResult> 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);
}
/// <summary>
/// Deletes a conversation and all its messages.
/// </summary>
public async Task<IResult> 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
});
}
/// <summary>
/// Adds items to a conversation.
/// </summary>
public async Task<IResult> 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<ItemResource> createdItems = [.. request.Items.Select(itemParam => itemParam.ToItemResource(idGenerator))];
await this._storage.AddItemsAsync(conversationId, createdItems, cancellationToken).ConfigureAwait(false);
return Results.Ok(new ListResponse<ItemResource>
{
Data = createdItems,
FirstId = createdItems.Count > 0 ? createdItems[0].Id : null,
LastId = createdItems.Count > 0 ? createdItems[^1].Id : null,
HasMore = false
});
}
/// <summary>
/// Lists items in a conversation.
/// </summary>
public async Task<IResult> 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);
}
/// <summary>
/// Retrieves a specific item.
/// </summary>
public async Task<IResult> 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"
}
});
}
/// <summary>
/// Deletes a specific item.
/// </summary>
public async Task<IResult> 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;
}
}
@@ -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;
/// <summary>
/// Optional service for indexing conversations by agent ID.
/// This is a non-standard extension to the OpenAI Conversations API.
/// </summary>
internal interface IAgentConversationIndex
{
/// <summary>
/// Adds a conversation to the index for the specified agent.
/// </summary>
/// <param name="agentId">The agent identifier.</param>
/// <param name="conversationId">The conversation identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task AddConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a conversation from the index for the specified agent.
/// </summary>
/// <param name="agentId">The agent identifier.</param>
/// <param name="conversationId">The conversation identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RemoveConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all conversation IDs for the specified agent.
/// </summary>
/// <param name="agentId">The agent identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list response containing conversation IDs associated with the agent.</returns>
Task<ListResponse<string>> GetConversationIdsAsync(string agentId, CancellationToken cancellationToken = default);
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal interface IConversationStorage
{
/// <summary>
/// Creates a new conversation.
/// </summary>
/// <param name="conversation">The conversation to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created conversation.</returns>
Task<Conversation> CreateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a conversation by ID.
/// </summary>
/// <param name="conversationId">The conversation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The conversation if found, null otherwise.</returns>
Task<Conversation?> GetConversationAsync(string conversationId, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing conversation.
/// </summary>
/// <param name="conversation">The conversation with updated values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated conversation if found, null otherwise.</returns>
Task<Conversation?> UpdateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a conversation and all its messages.
/// </summary>
/// <param name="conversationId">The conversation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteConversationAsync(string conversationId, CancellationToken cancellationToken = default);
// Item operations
/// <summary>
/// Adds multiple items to a conversation atomically.
/// Items are ItemResource objects from the Responses API.
/// </summary>
/// <param name="conversationId">The conversation ID to add the items to.</param>
/// <param name="items">The items to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when all items have been added.</returns>
Task AddItemsAsync(string conversationId, IEnumerable<ItemResource> items, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves an item by ID.
/// </summary>
/// <param name="conversationId">The conversation ID.</param>
/// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item if found, null otherwise.</returns>
Task<ItemResource?> GetItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists items in a conversation with pagination support.
/// </summary>
/// <param name="conversationId">The conversation ID.</param>
/// <param name="limit">Maximum number of items to return (default: 20, max: 100).</param>
/// <param name="order">Sort order (default: Descending).</param>
/// <param name="after">Cursor for pagination - return items after this ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list response with items and pagination info.</returns>
Task<ListResponse<ItemResource>> ListItemsAsync(
string conversationId,
int? limit = null,
SortOrder? order = null,
string? after = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a specific item from a conversation.
/// </summary>
/// <param name="conversationId">The conversation ID.</param>
/// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default);
}
@@ -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;
/// <summary>
/// In-memory implementation of IAgentConversationIndex for development and testing.
/// This is a non-standard extension to the OpenAI Conversations API.
/// </summary>
internal sealed class InMemoryAgentConversationIndex : IAgentConversationIndex, IDisposable
{
private readonly MemoryCache _cache;
private readonly InMemoryStorageOptions _options;
private sealed class ConversationSet
{
private readonly HashSet<string> _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<ConversationSet> 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!;
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc/>
public async Task<ListResponse<string>> 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<string>
{
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();
}
}
@@ -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;
/// <summary>
/// In-memory implementation of conversation storage for testing and development.
/// This implementation is thread-safe but data is not persisted across application restarts.
/// </summary>
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());
}
/// <inheritdoc />
public Task<Conversation> 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);
}
/// <inheritdoc />
public Task<Conversation?> GetConversationAsync(string conversationId, CancellationToken cancellationToken = default)
{
if (this._cache.TryGetValue(conversationId, out ConversationState? state) && state is not null)
{
return Task.FromResult<Conversation?>(state.Conversation);
}
return Task.FromResult<Conversation?>(null);
}
/// <inheritdoc />
public Task<Conversation?> 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?>(conversation);
}
return Task.FromResult<Conversation?>(null);
}
/// <inheritdoc />
public Task<bool> DeleteConversationAsync(string conversationId, CancellationToken cancellationToken = default)
{
if (this._cache.TryGetValue<ConversationState>(conversationId, out _))
{
this._cache.Remove(conversationId);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
/// <inheritdoc />
public Task AddItemsAsync(string conversationId, IEnumerable<ItemResource> 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;
}
/// <inheritdoc />
public Task<ItemResource?> 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<ItemResource?>(null);
}
/// <inheritdoc/>
public Task<ListResponse<ItemResource>> 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<ItemResource> 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<ItemResource>
{
Data = result,
FirstId = result.FirstOrDefault()?.Id,
LastId = result.LastOrDefault()?.Id,
HasMore = hasMore
});
}
/// <inheritdoc />
public Task<bool> 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);
}
/// <summary>
/// Encapsulates per-conversation state including items storage and synchronization.
/// </summary>
private sealed class ConversationState
{
#if NET9_0_OR_GREATER
private readonly OrderedDictionary<string, ItemResource> _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<ItemResource> 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<ItemResource> _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<ItemResource> 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();
}
}
@@ -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;
/// <summary>
/// Request to create items in a conversation.
/// </summary>
internal sealed class CreateItemsRequest
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("items")]
public required List<ItemParam> Items { get; init; }
}
@@ -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;
/// <summary>
/// Represents a conversation in the system.
/// </summary>
internal sealed record Conversation
{
/// <summary>
/// The unique identifier for the conversation.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// The object type, always "conversation".
/// </summary>
[JsonPropertyName("object")]
[SuppressMessage("Naming", "CA1720:Identifiers should not match keywords", Justification = "Matches OpenAI API specification")]
public string Object => "conversation";
/// <summary>
/// The Unix timestamp (in seconds) for when the conversation was created.
/// </summary>
[JsonPropertyName("created_at")]
public required long CreatedAt { get; init; }
/// <summary>
/// Set of 16 key-value pairs that can be attached to a conversation.
/// </summary>
[JsonPropertyName("metadata")]
public Dictionary<string, string> Metadata { get; init; } = [];
}
@@ -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;
/// <summary>
/// Request to create a new conversation.
/// </summary>
internal sealed class CreateConversationRequest
{
/// <summary>
/// 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).
/// </summary>
[JsonPropertyName("items")]
public List<ItemParam>? Items { get; init; }
/// <summary>
/// Set of 16 key-value pairs that can be attached to a conversation.
/// </summary>
[JsonPropertyName("metadata")]
public Dictionary<string, string>? Metadata { get; init; }
}
@@ -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;
/// <summary>
/// Request to update an existing conversation.
/// </summary>
internal sealed class UpdateConversationRequest
{
/// <summary>
/// Set of 16 key-value pairs that can be attached to a conversation.
/// </summary>
[JsonPropertyName("metadata")]
public required Dictionary<string, string> Metadata { get; init; }
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Hosting.OpenAI.Models;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
/// <summary>
/// Extension methods for <see cref="SortOrder"/>.
/// </summary>
internal static class SortOrderExtensions
{
/// <summary>
/// Converts a <see cref="SortOrder"/> to its string representation.
/// </summary>
/// <param name="order">The sort order.</param>
/// <returns>The string representation ("asc" or "desc").</returns>
public static string ToOrderString(this SortOrder order)
{
return order == SortOrder.Ascending ? "asc" : "desc";
}
/// <summary>
/// Checks if the sort order is ascending.
/// </summary>
/// <param name="order">The sort order.</param>
/// <returns>True if ascending, false otherwise.</returns>
public static bool IsAscending(this SortOrder order)
{
return order == SortOrder.Ascending;
}
}
@@ -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;
/// <summary>
/// Provides extension methods for mapping OpenAI Conversations API to an <see cref="IEndpointRouteBuilder"/>.
/// </summary>
public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps OpenAI Conversations API endpoints to the specified <see cref="IEndpointRouteBuilder"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Conversations endpoints to.</param>
public static IEndpointConventionBuilder MapOpenAIConversations(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var storage = endpoints.ServiceProvider.GetService<IConversationStorage>()
?? throw new InvalidOperationException("IConversationStorage is not registered. Call AddOpenAIConversations() in your service configuration.");
var conversationIndex = endpoints.ServiceProvider.GetService<IAgentConversationIndex>();
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;
}
}
@@ -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<InMemoryStorageOptions>() ?? new InMemoryStorageOptions();
var conversationStorage = endpoints.ServiceProvider.GetService<IConversationStorage>();
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<IResponsesService>()
?? 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<AIAgent>(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;
}
@@ -39,4 +39,18 @@ public static class MicrosoftAgentAIHostingOpenAIHostApplicationBuilderExtension
return builder;
}
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddOpenAIConversations(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOpenAIConversations();
return builder;
}
}
@@ -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;
/// <summary>
/// Generates IDs with partition keys.
/// </summary>
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
/// <summary>
/// Initializes a new instance of the <see cref="IdGenerator"/> class.
/// </summary>
/// <param name="responseId">The response ID.</param>
/// <param name="conversationId">The conversation ID.</param>
/// <param name="randomSeed">Optional random seed for deterministic ID generation. When null, uses cryptographically secure random generation.</param>
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;
}
/// <summary>
/// Creates a new ID generator from a create response request.
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>A new ID generator.</returns>
public static IdGenerator From(CreateResponse request)
{
string? responseId = null;
request.Metadata?.TryGetValue("response_id", out responseId);
return new IdGenerator(responseId, request.Conversation?.Id);
}
/// <summary>
/// Gets the response ID.
/// </summary>
public string ResponseId { get; }
/// <summary>
/// Gets the conversation ID.
/// </summary>
public string ConversationId { get; }
/// <summary>
/// Generates a new ID.
/// </summary>
/// <param name="category">The optional category for the ID.</param>
/// <returns>A generated ID string.</returns>
public string Generate(string? category = null)
{
var prefix = string.IsNullOrEmpty(category) ? "id" : category;
return NewId(prefix, partitionKey: this._partitionId, random: this._random);
}
/// <summary>
/// Generates a function call ID.
/// </summary>
/// <returns>A function call ID.</returns>
public string GenerateFunctionCallId() => this.Generate("func");
/// <summary>
/// Generates a function output ID.
/// </summary>
/// <returns>A function output ID.</returns>
public string GenerateFunctionOutputId() => this.Generate("funcout");
/// <summary>
/// Generates a message ID.
/// </summary>
/// <returns>A message ID.</returns>
public string GenerateMessageId() => this.Generate("msg");
/// <summary>
/// Generates a reasoning ID.
/// </summary>
/// <returns>A reasoning ID.</returns>
public string GenerateReasoningId() => this.Generate("rs");
/// <summary>
/// Generates a new ID with a structured format that includes a partition key.
/// </summary>
/// <param name="prefix">The prefix to add to the ID, typically indicating the resource type.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="infix">Optional additional text to insert between the prefix and the entropy.</param>
/// <param name="watermark">Optional text to insert in the middle of the entropy string for traceability.</param>
/// <param name="delimiter">The delimiter character used to separate parts of the ID.</param>
/// <param name="partitionKey">An explicit partition key to use. When provided, this value will be used instead of generating a new one.</param>
/// <param name="partitionKeyHint">An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.</param>
/// <param name="random">The random number generator.</param>
/// <returns>A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".</returns>
/// <exception cref="ArgumentException">Thrown when the watermark contains non-alphanumeric characters.</exception>
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}";
}
/// <summary>
/// Generates a secure random alphanumeric string of the specified length.
/// When a random seed was provided to the constructor, uses deterministic generation.
/// </summary>
/// <param name="stringLength">The desired length of the random string.</param>
/// <param name="random">The optional random number generator.</param>
/// <returns>A random alphanumeric string.</returns>
/// <exception cref="ArgumentException">Thrown when stringLength is less than 1.</exception>
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);
}
/// <summary>
/// Extracts the partition key from an existing ID, or returns null if extraction fails.
/// </summary>
/// <param name="id">The ID to extract the partition key from.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="delimiter">The delimiter character used in the ID.</param>
/// <returns>The partition key if successfully extracted; otherwise, null.</returns>
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..];
}
}
@@ -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;
/// <summary>
/// Shared helpers to generate IDs.
/// </summary>
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
/// <summary>
/// Generates a new ID with a structured format that includes a partition key.
/// </summary>
/// <param name="prefix">The prefix to add to the ID, typically indicating the resource type.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="infix">Optional additional text to insert between the prefix and the entropy.</param>
/// <param name="watermark">Optional text to insert in the middle of the entropy string for traceability.</param>
/// <param name="delimiter">The delimiter character used to separate parts of the ID.</param>
/// <param name="partitionKey">An explicit partition key to use. When provided, this value will be used instead of generating a new one.</param>
/// <param name="partitionKeyHint">An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.</param>
/// <returns>A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".</returns>
/// <exception cref="ArgumentException">Thrown when the watermark contains non-alphanumeric characters.</exception>
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}";
}
/// <summary>
/// Generates a secure random alphanumeric string of the specified length.
/// </summary>
/// <param name="stringLength">The desired length of the random string.</param>
/// <returns>A random alphanumeric string.</returns>
/// <exception cref="ArgumentException">Thrown when stringLength is less than 1.</exception>
public static string GetRandomString(int stringLength) =>
RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength);
/// <summary>
/// Extracts the partition key from an existing ID, or returns null if extraction fails.
/// </summary>
/// <param name="id">The ID to extract the partition key from.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="delimiter">The delimiter character used in the ID.</param>
/// <returns>The partition key if successfully extracted; otherwise, null.</returns>
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..];
}
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Caching.Memory;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Configuration options for in-memory storage implementations.
/// </summary>
internal sealed class InMemoryStorageOptions
{
/// <summary>
/// Gets or sets the maximum number of items to store in the cache.
/// Default is 1000. Set to null for no size limit.
/// </summary>
public long? SizeLimit { get; set; } = 1000;
/// <summary>
/// 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).
/// </summary>
public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }
/// <summary>
/// Gets or sets the sliding expiration for items in storage.
/// Items will be expired if not accessed within this timespan.
/// Default is 1 hour.
/// </summary>
public TimeSpan? SlidingExpiration { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Creates <see cref="MemoryCacheOptions"/> from these options.
/// </summary>
internal MemoryCacheOptions ToMemoryCacheOptions() => new()
{
SizeLimit = this.SizeLimit
};
/// <summary>
/// Creates <see cref="MemoryCacheEntryOptions"/> from these options.
/// </summary>
internal MemoryCacheEntryOptions ToMemoryCacheEntryOptions() => new()
{
AbsoluteExpirationRelativeToNow = this.AbsoluteExpirationRelativeToNow,
SlidingExpiration = this.SlidingExpiration,
Size = 1
};
}
@@ -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;
/// <summary>
/// Extension methods for <see cref="IMemoryCache"/> that provide atomic operations.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
internal static class MemoryCacheExtensions
{
private static readonly ConcurrentDictionary<(IMemoryCache, object), SemaphoreSlim> s_semaphores = new();
/// <summary>
/// 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.
/// </summary>
/// <typeparam name="T">The type of the object to get.</typeparam>
/// <param name="memoryCache">The <see cref="IMemoryCache"/> instance this method extends.</param>
/// <param name="key">The key of the entry to look for or create.</param>
/// <param name="factory">The factory that creates the value associated with this key if the key does not exist in the cache.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the value and a flag indicating whether it was created (true) or retrieved from cache (false).</returns>
public static async Task<T> GetOrCreateAtomicAsync<T>(
this IMemoryCache memoryCache,
object key,
Func<ICacheEntry, T> 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();
}
}
}
@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
<NoWarn>$(NoWarn);OPENAI001;MEAI001</NoWarn>
<RootNamespace>Microsoft.Agents.AI.Hosting.OpenAI</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
@@ -25,6 +25,7 @@
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
</ItemGroup>
<ItemGroup>
@@ -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;
/// <summary>
/// Response for a delete operation.
/// </summary>
internal sealed class DeleteResponse
{
/// <summary>
/// The ID of the deleted object.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// The object type.
/// </summary>
[JsonPropertyName("object")]
[SuppressMessage("Naming", "CA1720:Identifiers should not match keywords", Justification = "Matches OpenAI API specification")]
public required string Object { get; init; }
/// <summary>
/// Whether the object was successfully deleted.
/// </summary>
[JsonPropertyName("deleted")]
public required bool Deleted { get; init; }
}
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Models;
/// <summary>
/// Represents an error response from the OpenAI APIs.
/// </summary>
internal sealed class ErrorResponse
{
/// <summary>
/// Gets the error details.
/// </summary>
[JsonPropertyName("error")]
public required ErrorDetails Error { get; init; }
}
/// <summary>
/// Represents the details of an error.
/// </summary>
internal sealed class ErrorDetails
{
/// <summary>
/// Gets the error message.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
/// <summary>
/// Gets the error type.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Gets the error code.
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; init; }
/// <summary>
/// Gets the parameter that caused the error.
/// </summary>
[JsonPropertyName("param")]
public string? Param { get; init; }
}
@@ -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;
/// <summary>
/// Generic list response for paginated results.
/// Used across the OpenAI API for listing resources.
/// </summary>
internal sealed class ListResponse<T>
{
/// <summary>
/// The object type, always "list".
/// </summary>
[JsonPropertyName("object")]
[SuppressMessage("Naming", "CA1720:Identifiers should not match keywords", Justification = "Matches OpenAI API specification")]
public string Object => "list";
/// <summary>
/// The list of items.
/// </summary>
[JsonPropertyName("data")]
public required List<T> Data { get; init; }
/// <summary>
/// The ID of the first item in the list.
/// </summary>
[JsonPropertyName("first_id")]
public string? FirstId { get; init; }
/// <summary>
/// The ID of the last item in the list.
/// </summary>
[JsonPropertyName("last_id")]
public string? LastId { get; init; }
/// <summary>
/// Whether there are more items available.
/// </summary>
[JsonPropertyName("has_more")]
public required bool HasMore { get; init; }
}
@@ -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;
/// <summary>
/// Specifies the sort order for list operations.
/// </summary>
[JsonConverter(typeof(SortOrderJsonConverter))]
internal enum SortOrder
{
/// <summary>
/// Sort in ascending order (oldest to newest).
/// </summary>
Ascending,
/// <summary>
/// Sort in descending order (newest to oldest).
/// </summary>
Descending
}
/// <summary>
/// Custom JSON converter for SortOrder enum to serialize as "asc" and "desc".
/// </summary>
internal sealed class SortOrderJsonConverter : JsonConverter<SortOrder>
{
/// <inheritdoc/>
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}")
};
}
/// <inheritdoc/>
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);
}
}
@@ -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;
/// <summary>
/// Provides JSON serialization options and context for OpenAI Hosting APIs to support AOT and trimming.
/// </summary>
internal static class OpenAIHostingJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for OpenAI API serialization.
/// Includes support for AIContent types and all OpenAI-related types.
/// </summary>
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;
}
}
/// <summary>
/// Provides a unified JSON serialization context for all OpenAI Hosting APIs to support AOT and trimming.
/// Combines Conversations and Responses API types.
/// </summary>
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
AllowOutOfOrderMetadataProperties = true,
WriteIndented = false)]
[JsonSerializable(typeof(Dictionary<string, string>))]
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
AllowOutOfOrderMetadataProperties = true,
WriteIndented = false)]
// Conversations API types
[JsonSerializable(typeof(Conversation))]
[JsonSerializable(typeof(ListResponse<Conversation>))]
[JsonSerializable(typeof(CreateConversationRequest))]
[JsonSerializable(typeof(CreateItemsRequest))]
[JsonSerializable(typeof(UpdateConversationRequest))]
[JsonSerializable(typeof(ListResponse<ItemResource>))]
[JsonSerializable(typeof(List<Conversation>))]
// 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<InputMessage>))]
[JsonSerializable(typeof(IReadOnlyList<InputMessage>))]
[JsonSerializable(typeof(InputMessageContent))]
[JsonSerializable(typeof(ResponseStatus))]
[JsonSerializable(typeof(List<ItemContent>))]
[JsonSerializable(typeof(IList<ItemContent>))]
// 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<ItemResource>))]
[JsonSerializable(typeof(List<ItemResource>))]
// 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<ItemParam>))]
// ItemContent types
[JsonSerializable(typeof(List<ItemContent>))]
[JsonSerializable(typeof(IReadOnlyList<ItemContent>))]
[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<string, string>))]
[ExcludeFromCodeCoverage]
internal sealed partial class ResponsesJsonContext : JsonSerializerContext;
internal sealed partial class OpenAIHostingJsonContext : JsonSerializerContext;
@@ -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;
/// <summary>
/// Response executor that uses an AIAgent to execute responses locally.
/// This is the default implementation for local execution.
/// </summary>
internal sealed class AIAgentResponseExecutor : IResponseExecutor
{
private readonly AIAgent _agent;
public AIAgentResponseExecutor(AIAgent agent)
{
ArgumentNullException.ThrowIfNull(agent);
this._agent = agent;
}
public async IAsyncEnumerable<StreamingResponseEvent> 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<ChatMessage>();
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;
}
}
}
@@ -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;
/// <summary>
/// OpenAI Responses processor for <see cref="AIAgent"/>.
/// </summary>
internal static class AIAgentResponsesProcessor
{
public static async Task<IResult> 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<IHttpResponseBodyFeature>().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<StreamingResponseEvent>(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);
}
}
}
@@ -29,5 +29,5 @@ internal sealed class AgentInvocationContext(IdGenerator idGenerator, JsonSerial
/// <summary>
/// Gets the JSON serializer options.
/// </summary>
public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? ResponsesJsonSerializerOptions.Default;
public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? OpenAIHostingJsonUtilities.DefaultOptions;
}
@@ -15,6 +15,8 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
/// </summary>
internal static class AgentRunResponseExtensions
{
private static ChatRole s_DeveloperRole => new("developer");
/// <summary>
/// Converts an AgentRunResponse to a Response model.
/// </summary>
@@ -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<string, string> metadata ? new Dictionary<string, string>(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<string, string> metadata ? new Dictionary<string, string>(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
/// <returns>An enumerable of ItemResource objects.</returns>
public static IEnumerable<ItemResource> ToItemResource(this ChatMessage message, IdGenerator idGenerator, JsonSerializerOptions jsonSerializerOptions)
{
IList<ItemContent> contents = [];
foreach (var content in message.Contents)
List<ItemContent> 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<ItemContent> 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
};
}
/// <summary>
/// Converts an InputMessage to ItemResource objects.
/// </summary>
/// <param name="inputMessage">The input message to convert.</param>
/// <param name="idGenerator">The ID generator to use for creating IDs.</param>
/// <returns>An enumerable of ItemResource objects.</returns>
public static IEnumerable<ItemResource> ToItemResource(this InputMessage inputMessage, IdGenerator idGenerator)
{
// Convert InputMessageContent to ItemContent array
List<ItemContent> 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
};
}
/// <summary>
/// Converts UsageDetails to ResponseUsage.
/// </summary>
@@ -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
/// <param name="context">The agent invocation context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A stream of response events.</returns>
internal static async IAsyncEnumerable<StreamingResponseEvent> ToStreamingResponseAsync(
public static async IAsyncEnumerable<StreamingResponseEvent> ToStreamingResponseAsync(
this IAsyncEnumerable<AgentRunResponseUpdate> 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<string, string>(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<string, string>(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: "")
};
}
}
@@ -11,6 +11,23 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
/// </summary>
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";
/// <summary>
/// Converts <see cref="ItemContent"/> to <see cref="AIContent"/>.
/// </summary>
@@ -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
@@ -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;
/// <summary>
/// JSON converter for ItemParam that handles polymorphic deserialization based on the "type" discriminator.
/// </summary>
internal sealed class ItemParamConverter : JsonConverter<ItemParam>
{
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()));
}
}
@@ -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;
/// <summary>
/// JSON converter for ItemResource that handles type discrimination.
/// </summary>
[ExcludeFromCodeCoverage]
internal sealed class ItemResourceConverter : JsonConverter<ItemResource>
{
private readonly ResponsesJsonContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="ItemResourceConverter"/> class.
/// </summary>
public ItemResourceConverter()
{
this._context = ResponsesJsonContext.Default;
}
/// <inheritdoc/>
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
};
}
/// <inheritdoc/>
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}");
@@ -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;
/// <summary>
/// JSON converter for ResponsesMessageItemParam that handles role-based polymorphic deserialization.
/// </summary>
internal sealed class ResponsesMessageItemParamConverter : JsonConverter<ResponsesMessageItemParam>
{
/// <inheritdoc/>
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}")
};
}
/// <inheritdoc/>
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}");
}
}
}
@@ -14,82 +14,47 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
[ExcludeFromCodeCoverage]
internal sealed class ResponsesMessageItemResourceConverter : JsonConverter<ResponsesMessageItemResource>
{
private readonly ResponsesJsonContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="ResponsesMessageItemResourceConverter"/> class.
/// </summary>
public ResponsesMessageItemResourceConverter()
{
this._context = ResponsesJsonContext.Default;
}
/// <inheritdoc/>
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}")
};
}
/// <inheritdoc/>
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}");
@@ -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.
/// </summary>
/// <typeparam name="T">The enum type to convert.</typeparam>
[ExcludeFromCodeCoverage]
internal sealed class SnakeCaseEnumConverter<T> : JsonStringEnumConverter<T> where T : struct, Enum
{
/// <summary>
/// Creates a new instance of the <see cref="SnakeCaseEnumConverter{T}"/> class.
/// </summary>
public SnakeCaseEnumConverter() : base(JsonNamingPolicy.SnakeCaseLower)
{
}
@@ -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;
/// <summary>
/// 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().
/// </summary>
internal sealed class HostedAgentResponseExecutor : IResponseExecutor
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<HostedAgentResponseExecutor> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="HostedAgentResponseExecutor"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider used to resolve hosted agents.</param>
/// <param name="logger">The logger instance.</param>
public HostedAgentResponseExecutor(
IServiceProvider serviceProvider,
ILogger<HostedAgentResponseExecutor> logger)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
}
/// <inheritdoc/>
public async IAsyncEnumerable<StreamingResponseEvent> 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<ChatMessage>();
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;
}
}
/// <summary>
/// Resolves an agent from the service provider based on the request.
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>The resolved AIAgent instance.</returns>
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
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<AIAgent>(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);
}
}
/// <summary>
/// Validates that the agent can be resolved without actually resolving it.
/// This allows early validation before starting async execution.
/// </summary>
/// <param name="request">The create response request.</param>
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
public void ValidateAgent(CreateResponse request)
{
// Use the same logic as ResolveAgent but don't return the agent
_ = this.ResolveAgent(request);
}
}
@@ -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;
/// <summary>
/// Interface for executing response generation.
/// Implementations can use local execution (AIAgent) or forward to remote workers.
/// </summary>
internal interface IResponseExecutor
{
/// <summary>
/// Executes a response generation request and returns streaming events.
/// </summary>
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
/// <param name="request">The create response request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of streaming response events.</returns>
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
CancellationToken cancellationToken = default);
}
@@ -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;
/// <summary>
/// Service interface for handling OpenAI Responses API operations.
/// Implementations can use various storage and execution strategies (in-memory, Orleans grains, etc.).
/// </summary>
internal interface IResponsesService
{
/// <summary>
/// Default limit for list operations.
/// </summary>
const int DefaultListLimit = 20;
/// <summary>
/// Creates a model response for the given input.
/// </summary>
/// <param name="request">The create response request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created response.</returns>
Task<Response> CreateResponseAsync(
CreateResponse request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a streaming model response for the given input.
/// </summary>
/// <param name="request">The create response request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of streaming response events.</returns>
IAsyncEnumerable<StreamingResponseEvent> CreateResponseStreamingAsync(
CreateResponse request,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a response by ID.
/// </summary>
/// <param name="responseId">The ID of the response to retrieve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The response if found, null otherwise.</returns>
Task<Response?> GetResponseAsync(
string responseId,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a response by ID in streaming mode, yielding events as they become available.
/// </summary>
/// <param name="responseId">The ID of the response to retrieve.</param>
/// <param name="startingAfter">The sequence number after which to start streaming. If null, starts from the beginning.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of streaming updates.</returns>
IAsyncEnumerable<StreamingResponseEvent> GetResponseStreamingAsync(
string responseId,
int? startingAfter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancels an in-progress response.
/// </summary>
/// <param name="responseId">The ID of the response to cancel.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated response after cancellation.</returns>
Task<Response> CancelResponseAsync(
string responseId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a response by ID.
/// </summary>
/// <param name="responseId">The ID of the response to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the response was deleted, false if it was not found.</returns>
Task<bool> DeleteResponseAsync(
string responseId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists the input items for a response.
/// </summary>
/// <param name="responseId">The ID of the response.</param>
/// <param name="limit">Maximum number of items to return (1-100). Defaults to <see cref="DefaultListLimit"/> if null.</param>
/// <param name="order">Sort order. Defaults to <see cref="SortOrder.Descending"/> if null.</param>
/// <param name="after">Return items after this ID.</param>
/// <param name="before">Return items before this ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list response with items and pagination info.</returns>
Task<ListResponse<ItemResource>> ListResponseInputItemsAsync(
string responseId,
int? limit = null,
SortOrder? order = null,
string? after = null,
string? before = null,
CancellationToken cancellationToken = default);
}
@@ -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;
/// <summary>
/// Generates IDs with partition keys.
/// </summary>
internal sealed partial class IdGenerator
{
private readonly string _partitionId;
/// <summary>
/// Initializes a new instance of the <see cref="IdGenerator"/> class.
/// </summary>
/// <param name="responseId">The response ID.</param>
/// <param name="conversationId">The conversation ID.</param>
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;
}
/// <summary>
/// Creates a new ID generator from a create response request.
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>A new ID generator.</returns>
public static IdGenerator From(CreateResponse request)
{
string? responseId = null;
request.Metadata?.TryGetValue("response_id", out responseId);
return new IdGenerator(responseId, request.Conversation?.Id);
}
/// <summary>
/// Gets the response ID.
/// </summary>
public string ResponseId { get; }
/// <summary>
/// Gets the conversation ID.
/// </summary>
public string ConversationId { get; }
/// <summary>
/// Generates a new ID.
/// </summary>
/// <param name="category">The optional category for the ID.</param>
/// <returns>A generated ID string.</returns>
public string Generate(string? category = null)
{
var prefix = string.IsNullOrEmpty(category) ? "id" : category;
return IdGeneratorHelpers.NewId(prefix, partitionKey: this._partitionId);
}
/// <summary>
/// Generates a function call ID.
/// </summary>
/// <returns>A function call ID.</returns>
public string GenerateFunctionCallId() => this.Generate("func");
/// <summary>
/// Generates a function output ID.
/// </summary>
/// <returns>A function output ID.</returns>
public string GenerateFunctionOutputId() => this.Generate("funcout");
/// <summary>
/// Generates a message ID.
/// </summary>
/// <returns>A message ID.</returns>
public string GenerateMessageId() => this.Generate("msg");
/// <summary>
/// Generates a reasoning ID.
/// </summary>
/// <returns>A reasoning ID.</returns>
public string GenerateReasoningId() => this.Generate("rs");
}
@@ -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;
/// <summary>
/// In-memory implementation of responses service for testing and development.
/// This implementation is thread-safe but data is not persisted across application restarts.
/// </summary>
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<int, ItemResource> _outputItems = [];
public Response? Response { get; set; }
public CreateResponse? Request { get; set; }
public List<StreamingResponseEvent> 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<ItemResource> outputList = [.. this._outputItems.OrderBy(kvp => kvp.Key).Select(kvp => kvp.Value)];
this.Response = this.Response with { Output = outputList };
}
}
public async IAsyncEnumerable<StreamingResponseEvent> 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<StreamingResponseEvent> 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<Response> 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<StreamingResponseEvent> 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<Response?> GetResponseAsync(string responseId, CancellationToken cancellationToken = default)
{
this._cache.TryGetValue(responseId, out ResponseState? state);
return Task.FromResult(state?.Response);
}
public async IAsyncEnumerable<StreamingResponseEvent> 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<Response> 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<bool> 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<ListResponse<ItemResource>> 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<ItemResource>
{
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<ItemResource> 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<ItemResource>(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<ItemResource> GetInputItems(string responseId, ResponseState state)
{
var itemResources = new List<ItemResource>();
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();
}
}
@@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// <summary>
/// Represents an agent identifier.
/// </summary>
internal sealed record AgentId
internal sealed class AgentId
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentId"/> class.
@@ -44,7 +44,7 @@ internal sealed record AgentId
/// <summary>
/// Represents an agent ID type.
/// </summary>
internal sealed record AgentIdType
internal sealed class AgentIdType
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentIdType"/> class.
@@ -65,7 +65,7 @@ internal sealed record AgentIdType
/// <summary>
/// Represents an agent reference.
/// </summary>
internal sealed record AgentReference
internal sealed class AgentReference
{
/// <summary>
/// The type of the reference (e.g., "agent" or "agent_reference").
@@ -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.
/// </summary>
[JsonConverter(typeof(ConversationReferenceJsonConverter))]
internal sealed record ConversationReference
internal sealed class ConversationReference
{
/// <summary>
/// The conversation ID.
@@ -42,6 +42,7 @@ internal sealed record ConversationReference
/// </summary>
internal sealed class ConversationReferenceJsonConverter : JsonConverter<ConversationReference>
{
/// <inheritdoc/>
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<Convers
if (root.TryGetProperty("metadata", out var metadataProp) && metadataProp.ValueKind == JsonValueKind.Object)
{
metadata = JsonSerializer.Deserialize(metadataProp.GetRawText(), ResponsesJsonContext.Default.DictionaryStringString);
metadata = JsonSerializer.Deserialize(metadataProp.GetRawText(), OpenAIHostingJsonContext.Default.DictionaryStringString);
}
return id is null ? null : ConversationReference.FromObject(id, metadata);
@@ -74,6 +75,7 @@ internal sealed class ConversationReferenceJsonConverter : JsonConverter<Convers
throw new JsonException($"Unexpected token type for ConversationReference: {reader.TokenType}");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ConversationReference value, JsonSerializerOptions options)
{
if (value is null)
@@ -95,7 +97,7 @@ internal sealed class ConversationReferenceJsonConverter : JsonConverter<Convers
if (value.Metadata is not null)
{
writer.WritePropertyName("metadata");
JsonSerializer.Serialize(writer, value.Metadata, ResponsesJsonContext.Default.DictionaryStringString);
JsonSerializer.Serialize(writer, value.Metadata, OpenAIHostingJsonContext.Default.DictionaryStringString);
}
writer.WriteEndObject();
}
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// <summary>
/// Request to create a model response.
/// </summary>
internal sealed record CreateResponse
internal sealed class CreateResponse
{
/// <summary>
/// Text, image, or file inputs to the model, used to generate a response.
@@ -65,7 +65,10 @@ internal sealed record CreateResponse
/// <summary>
/// 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.
/// </summary>
[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.
/// </summary>
[JsonPropertyName("include")]
public IReadOnlyList<string>? Include { get; init; }
public List<string>? Include { get; init; }
/// <summary>
/// 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.
/// </summary>
[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.
/// </summary>
[JsonPropertyName("tools")]
public IReadOnlyList<JsonElement>? Tools { get; init; }
public List<JsonElement>? Tools { get; init; }
/// <summary>
/// How the model should select which tool (or tools) to use when generating a response.
@@ -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.
/// </summary>
internal sealed record InputMessage
internal sealed class InputMessage
{
/// <summary>
/// The role of the message input. One of user, assistant, system, or developer.
@@ -22,7 +22,7 @@ internal sealed class InputMessageContent : IEquatable<InputMessageContent>
this.Contents = null;
}
private InputMessageContent(IReadOnlyList<ItemContent> contents)
private InputMessageContent(List<ItemContent> contents)
{
this.Contents = contents ?? throw new ArgumentNullException(nameof(contents));
this.Text = null;
@@ -36,12 +36,12 @@ internal sealed class InputMessageContent : IEquatable<InputMessageContent>
/// <summary>
/// Creates an InputMessageContent from a list of ItemContent items.
/// </summary>
public static InputMessageContent FromContents(IReadOnlyList<ItemContent> contents) => new(contents);
public static InputMessageContent FromContents(List<ItemContent> contents) => new(contents);
/// <summary>
/// Creates an InputMessageContent from a list of ItemContent items.
/// </summary>
public static InputMessageContent FromContents(params ItemContent[] contents) => new(contents);
public static InputMessageContent FromContents(params ItemContent[] contents) => new([.. contents]);
/// <summary>
/// Implicit conversion from string to InputMessageContent.
@@ -62,12 +62,14 @@ internal sealed class InputMessageContent : IEquatable<InputMessageContent>
/// Gets whether this content is text.
/// </summary>
[MemberNotNullWhen(true, nameof(Text))]
[MemberNotNullWhen(false, nameof(Contents))]
public bool IsText => this.Text is not null;
/// <summary>
/// Gets whether this content is a list of ItemContent items.
/// </summary>
[MemberNotNullWhen(true, nameof(Contents))]
[MemberNotNullWhen(false, nameof(Text))]
public bool IsContents => this.Contents is not null;
/// <summary>
@@ -78,7 +80,7 @@ internal sealed class InputMessageContent : IEquatable<InputMessageContent>
/// <summary>
/// Gets the ItemContent items, or null if this is not a content list.
/// </summary>
public IReadOnlyList<ItemContent>? Contents { get; }
public List<ItemContent>? Contents { get; }
/// <inheritdoc/>
public bool Equals(InputMessageContent? other)
@@ -143,6 +145,16 @@ internal sealed class InputMessageContent : IEquatable<InputMessageContent>
{
return !Equals(left, right);
}
/// <summary>
/// Converts this instance to a list of ItemContent.
/// </summary>
public List<ItemContent> ToItemContents()
{
return this.IsText
? [new ItemContentInputText { Text = this.Text }]
: this.Contents;
}
}
/// <summary>
@@ -150,6 +162,7 @@ internal sealed class InputMessageContent : IEquatable<InputMessageContent>
/// </summary>
internal sealed class InputMessageContentJsonConverter : JsonConverter<InputMessageContent>
{
/// <inheritdoc/>
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<InputMess
// Check if it's an array of ItemContent
if (reader.TokenType == JsonTokenType.StartArray)
{
var contents = JsonSerializer.Deserialize(ref reader, ResponsesJsonContext.Default.ListItemContent);
var contents = JsonSerializer.Deserialize(ref reader, OpenAIHostingJsonContext.Default.ListItemContent);
return contents?.Count > 0
? InputMessageContent.FromContents(contents)
: InputMessageContent.FromText(string.Empty);
@@ -171,6 +184,7 @@ internal sealed class InputMessageContentJsonConverter : JsonConverter<InputMess
throw new JsonException($"Unexpected token type for InputMessageContent: {reader.TokenType}");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, InputMessageContent value, JsonSerializerOptions options)
{
if (value.IsText)
@@ -179,7 +193,7 @@ internal sealed class InputMessageContentJsonConverter : JsonConverter<InputMess
}
else if (value.IsContents)
{
JsonSerializer.Serialize(writer, value.Contents, ResponsesJsonContext.Default.ListItemContent);
JsonSerializer.Serialize(writer, value.Contents, OpenAIHostingJsonContext.Default.ListItemContent);
}
else
{
@@ -0,0 +1,577 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// <summary>
/// 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.
/// </summary>
[JsonConverter(typeof(ItemParamConverter))]
internal abstract class ItemParam
{
/// <summary>
/// The type of the item.
/// </summary>
[JsonPropertyName("type")]
public abstract string Type { get; }
}
/// <summary>
/// Base class for message item parameters.
/// </summary>
[JsonConverter(typeof(ResponsesMessageItemParamConverter))]
internal abstract class ResponsesMessageItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for message items.
/// </summary>
public const string ItemType = "message";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The role of the message sender.
/// </summary>
[JsonPropertyName("role")]
public abstract ChatRole Role { get; }
}
/// <summary>
/// A user message item parameter.
/// </summary>
internal sealed class ResponsesUserMessageItemParam : ResponsesMessageItemParam
{
/// <summary>
/// The constant role type identifier for user messages.
/// </summary>
public const string RoleType = "user";
/// <inheritdoc/>
public override ChatRole Role => ChatRole.User;
/// <summary>
/// The content of the message. Can be a simple string or an array of content parts.
/// </summary>
[JsonPropertyName("content")]
public required InputMessageContent Content { get; init; }
}
/// <summary>
/// An assistant message item parameter.
/// </summary>
internal sealed class ResponsesAssistantMessageItemParam : ResponsesMessageItemParam
{
/// <summary>
/// The constant role type identifier for assistant messages.
/// </summary>
public const string RoleType = "assistant";
/// <inheritdoc/>
public override ChatRole Role => ChatRole.Assistant;
/// <summary>
/// The content of the message. Can be a simple string or an array of content parts.
/// </summary>
[JsonPropertyName("content")]
public required InputMessageContent Content { get; init; }
}
/// <summary>
/// A system message item parameter.
/// </summary>
internal sealed class ResponsesSystemMessageItemParam : ResponsesMessageItemParam
{
/// <summary>
/// The constant role type identifier for system messages.
/// </summary>
public const string RoleType = "system";
/// <inheritdoc/>
public override ChatRole Role => ChatRole.System;
/// <summary>
/// The content of the message. Can be a simple string or an array of content parts.
/// </summary>
[JsonPropertyName("content")]
public required InputMessageContent Content { get; init; }
}
/// <summary>
/// A developer message item parameter.
/// </summary>
internal sealed class ResponsesDeveloperMessageItemParam : ResponsesMessageItemParam
{
/// <summary>
/// The constant role type identifier for developer messages.
/// </summary>
public const string RoleType = "developer";
/// <inheritdoc/>
public override ChatRole Role => new(RoleType);
/// <summary>
/// The content of the message. Can be a simple string or an array of content parts.
/// </summary>
[JsonPropertyName("content")]
public required InputMessageContent Content { get; init; }
}
/// <summary>
/// A function tool call item parameter.
/// </summary>
internal sealed class FunctionToolCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for function call items.
/// </summary>
public const string ItemType = "function_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The call ID of the function.
/// </summary>
[JsonPropertyName("call_id")]
public required string CallId { get; init; }
/// <summary>
/// The name of the function.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// The arguments to the function.
/// </summary>
[JsonPropertyName("arguments")]
public required string Arguments { get; init; }
}
/// <summary>
/// A function tool call output item parameter.
/// </summary>
internal sealed class FunctionToolCallOutputItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for function call output items.
/// </summary>
public const string ItemType = "function_call_output";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The call ID of the function.
/// </summary>
[JsonPropertyName("call_id")]
public required string CallId { get; init; }
/// <summary>
/// The output of the function.
/// </summary>
[JsonPropertyName("output")]
public required string Output { get; init; }
}
/// <summary>
/// A file search tool call item parameter.
/// </summary>
internal sealed class FileSearchToolCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for file search call items.
/// </summary>
public const string ItemType = "file_search_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The queries used to search for files.
/// </summary>
[JsonPropertyName("queries")]
public List<string>? Queries { get; init; }
/// <summary>
/// The results of the file search tool call.
/// </summary>
[JsonPropertyName("results")]
public List<JsonElement>? Results { get; init; }
}
/// <summary>
/// A computer tool call item parameter.
/// </summary>
internal sealed class ComputerToolCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for computer call items.
/// </summary>
public const string ItemType = "computer_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// An identifier used when responding to the tool call with output.
/// </summary>
[JsonPropertyName("call_id")]
public required string CallId { get; init; }
/// <summary>
/// The action to perform.
/// </summary>
[JsonPropertyName("action")]
public required JsonElement Action { get; init; }
/// <summary>
/// The pending safety checks for the computer call.
/// </summary>
[JsonPropertyName("pending_safety_checks")]
public List<JsonElement>? PendingSafetyChecks { get; init; }
}
/// <summary>
/// A computer tool call output item parameter.
/// </summary>
internal sealed class ComputerToolCallOutputItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for computer call output items.
/// </summary>
public const string ItemType = "computer_call_output";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The ID of the computer tool call that produced the output.
/// </summary>
[JsonPropertyName("call_id")]
public required string CallId { get; init; }
/// <summary>
/// The safety checks reported by the API that have been acknowledged by the developer.
/// </summary>
[JsonPropertyName("acknowledged_safety_checks")]
public List<JsonElement>? AcknowledgedSafetyChecks { get; init; }
/// <summary>
/// The output of the computer tool call.
/// </summary>
[JsonPropertyName("output")]
public required JsonElement Output { get; init; }
}
/// <summary>
/// A web search tool call item parameter.
/// </summary>
internal sealed class WebSearchToolCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for web search call items.
/// </summary>
public const string ItemType = "web_search_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// An object describing the specific action taken in this web search call.
/// </summary>
[JsonPropertyName("action")]
public required JsonElement Action { get; init; }
}
/// <summary>
/// A reasoning item parameter.
/// </summary>
internal sealed class ReasoningItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for reasoning items.
/// </summary>
public const string ItemType = "reasoning";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The encrypted content of the reasoning item.
/// </summary>
[JsonPropertyName("encrypted_content")]
public string? EncryptedContent { get; init; }
/// <summary>
/// Reasoning text contents.
/// </summary>
[JsonPropertyName("summary")]
public List<JsonElement>? Summary { get; init; }
}
/// <summary>
/// An item reference item parameter.
/// </summary>
internal sealed class ItemReferenceItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for item reference items.
/// </summary>
public const string ItemType = "item_reference";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The service-originated ID of the previously generated response item being referenced.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
}
/// <summary>
/// An image generation tool call item parameter.
/// </summary>
internal sealed class ImageGenerationToolCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for image generation call items.
/// </summary>
public const string ItemType = "image_generation_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The generated image encoded in base64.
/// </summary>
[JsonPropertyName("result")]
public string? Result { get; init; }
}
/// <summary>
/// A code interpreter tool call item parameter.
/// </summary>
internal sealed class CodeInterpreterToolCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for code interpreter call items.
/// </summary>
public const string ItemType = "code_interpreter_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The ID of the container used to run the code.
/// </summary>
[JsonPropertyName("container_id")]
public string? ContainerId { get; init; }
/// <summary>
/// The code to run, or null if not available.
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; init; }
/// <summary>
/// The outputs generated by the code interpreter, such as logs or images.
/// Can be null if no outputs are available.
/// </summary>
[JsonPropertyName("outputs")]
public List<JsonElement>? Outputs { get; init; }
}
/// <summary>
/// A local shell tool call item parameter.
/// </summary>
internal sealed class LocalShellToolCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for local shell call items.
/// </summary>
public const string ItemType = "local_shell_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The unique ID of the local shell tool call generated by the model.
/// </summary>
[JsonPropertyName("call_id")]
public string? CallId { get; init; }
/// <summary>
/// The action to execute.
/// </summary>
[JsonPropertyName("action")]
public JsonElement? Action { get; init; }
}
/// <summary>
/// A local shell tool call output item parameter.
/// </summary>
internal sealed class LocalShellToolCallOutputItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for local shell call output items.
/// </summary>
public const string ItemType = "local_shell_call_output";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// A JSON string of the output of the local shell tool call.
/// </summary>
[JsonPropertyName("output")]
public string? Output { get; init; }
}
/// <summary>
/// An MCP list tools item parameter.
/// </summary>
internal sealed class MCPListToolsItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for MCP list tools items.
/// </summary>
public const string ItemType = "mcp_list_tools";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The label of the MCP server.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
/// <summary>
/// The tools available on the server.
/// </summary>
[JsonPropertyName("tools")]
public List<JsonElement>? Tools { get; init; }
/// <summary>
/// Error message if the server could not list tools.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// An MCP approval request item parameter.
/// </summary>
internal sealed class MCPApprovalRequestItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for MCP approval request items.
/// </summary>
public const string ItemType = "mcp_approval_request";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The label of the MCP server making the request.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
/// <summary>
/// The name of the tool to run.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// A JSON string of arguments for the tool.
/// </summary>
[JsonPropertyName("arguments")]
public string? Arguments { get; init; }
}
/// <summary>
/// An MCP approval response item parameter.
/// </summary>
internal sealed class MCPApprovalResponseItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for MCP approval response items.
/// </summary>
public const string ItemType = "mcp_approval_response";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The ID of the approval request being answered.
/// </summary>
[JsonPropertyName("approval_request_id")]
public string? ApprovalRequestId { get; init; }
/// <summary>
/// Whether the request was approved.
/// </summary>
[JsonPropertyName("approve")]
public bool? Approve { get; init; }
/// <summary>
/// Optional reason for the decision.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// An MCP call item parameter.
/// </summary>
internal sealed class MCPCallItemParam : ItemParam
{
/// <summary>
/// The constant item type identifier for MCP call items.
/// </summary>
public const string ItemType = "mcp_call";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The label of the MCP server running the tool.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
/// <summary>
/// The name of the tool that was run.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// A JSON string of the arguments passed to the tool.
/// </summary>
[JsonPropertyName("arguments")]
public string? Arguments { get; init; }
/// <summary>
/// The output from the tool call.
/// </summary>
[JsonPropertyName("output")]
public string? Output { get; init; }
/// <summary>
/// The error from the tool call, if any.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}
@@ -0,0 +1,157 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// <summary>
/// Extension methods for converting ItemParam (input) to ItemResource (output).
/// </summary>
internal static class ItemParamExtensions
{
/// <summary>
/// Converts an ItemParam (input model) to an ItemResource (output model) by adding server-generated fields.
/// </summary>
/// <param name="param">The input item parameter.</param>
/// <param name="idGenerator">The ID generator to use for creating item IDs.</param>
/// <returns>An ItemResource with a generated ID.</returns>
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}")
};
}
}
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// Base class for all item resources (output items from a response).
/// </summary>
[JsonConverter(typeof(ItemResourceConverter))]
internal abstract record ItemResource
internal abstract class ItemResource
{
/// <summary>
/// The unique identifier for the item.
@@ -31,7 +31,7 @@ internal abstract record ItemResource
/// Base class for message item resources.
/// </summary>
[JsonConverter(typeof(ResponsesMessageItemResourceConverter))]
internal abstract record ResponsesMessageItemResource : ItemResource
internal abstract class ResponsesMessageItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for message items.
@@ -57,7 +57,7 @@ internal abstract record ResponsesMessageItemResource : ItemResource
/// <summary>
/// An assistant message item resource.
/// </summary>
internal sealed record ResponsesAssistantMessageItemResource : ResponsesMessageItemResource
internal sealed class ResponsesAssistantMessageItemResource : ResponsesMessageItemResource
{
/// <summary>
/// The constant role type identifier for assistant messages.
@@ -71,13 +71,13 @@ internal sealed record ResponsesAssistantMessageItemResource : ResponsesMessageI
/// The content of the message.
/// </summary>
[JsonPropertyName("content")]
public required IList<ItemContent> Content { get; init; }
public required List<ItemContent> Content { get; init; }
}
/// <summary>
/// A user message item resource.
/// </summary>
internal sealed record ResponsesUserMessageItemResource : ResponsesMessageItemResource
internal sealed class ResponsesUserMessageItemResource : ResponsesMessageItemResource
{
/// <summary>
/// The constant role type identifier for user messages.
@@ -91,13 +91,13 @@ internal sealed record ResponsesUserMessageItemResource : ResponsesMessageItemRe
/// The content of the message.
/// </summary>
[JsonPropertyName("content")]
public required IList<ItemContent> Content { get; init; }
public required List<ItemContent> Content { get; init; }
}
/// <summary>
/// A system message item resource.
/// </summary>
internal sealed record ResponsesSystemMessageItemResource : ResponsesMessageItemResource
internal sealed class ResponsesSystemMessageItemResource : ResponsesMessageItemResource
{
/// <summary>
/// The constant role type identifier for system messages.
@@ -111,13 +111,13 @@ internal sealed record ResponsesSystemMessageItemResource : ResponsesMessageItem
/// The content of the message.
/// </summary>
[JsonPropertyName("content")]
public required IList<ItemContent> Content { get; init; }
public required List<ItemContent> Content { get; init; }
}
/// <summary>
/// A developer message item resource.
/// </summary>
internal sealed record ResponsesDeveloperMessageItemResource : ResponsesMessageItemResource
internal sealed class ResponsesDeveloperMessageItemResource : ResponsesMessageItemResource
{
/// <summary>
/// The constant role type identifier for developer messages.
@@ -131,13 +131,13 @@ internal sealed record ResponsesDeveloperMessageItemResource : ResponsesMessageI
/// The content of the message.
/// </summary>
[JsonPropertyName("content")]
public required IList<ItemContent> Content { get; init; }
public required List<ItemContent> Content { get; init; }
}
/// <summary>
/// A function tool call item resource.
/// </summary>
internal sealed record FunctionToolCallItemResource : ItemResource
internal sealed class FunctionToolCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for function call items.
@@ -175,7 +175,7 @@ internal sealed record FunctionToolCallItemResource : ItemResource
/// <summary>
/// A function tool call output item resource.
/// </summary>
internal sealed record FunctionToolCallOutputItemResource : ItemResource
internal sealed class FunctionToolCallOutputItemResource : ItemResource
{
/// <summary>
/// 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.
/// </summary>
[JsonConverter(typeof(SnakeCaseEnumConverter<ResponsesMessageItemResourceStatus>))]
public enum ResponsesMessageItemResourceStatus
internal enum ResponsesMessageItemResourceStatus
{
/// <summary>
/// The message is completed.
@@ -230,7 +230,7 @@ public enum ResponsesMessageItemResourceStatus
/// The status of a function tool call item resource.
/// </summary>
[JsonConverter(typeof(SnakeCaseEnumConverter<FunctionToolCallItemResourceStatus>))]
public enum FunctionToolCallItemResourceStatus
internal enum FunctionToolCallItemResourceStatus
{
/// <summary>
/// The function call is completed.
@@ -247,7 +247,7 @@ public enum FunctionToolCallItemResourceStatus
/// The status of a function tool call output item resource.
/// </summary>
[JsonConverter(typeof(SnakeCaseEnumConverter<FunctionToolCallOutputItemResourceStatus>))]
public enum FunctionToolCallOutputItemResourceStatus
internal enum FunctionToolCallOutputItemResourceStatus
{
/// <summary>
/// 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
{
/// <summary>
/// The type of the content.
@@ -285,7 +285,7 @@ internal abstract record ItemContent
/// <summary>
/// Text input content.
/// </summary>
internal sealed record ItemContentInputText : ItemContent
internal sealed class ItemContentInputText : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
@@ -301,7 +301,7 @@ internal sealed record ItemContentInputText : ItemContent
/// <summary>
/// Audio input content.
/// </summary>
internal sealed record ItemContentInputAudio : ItemContent
internal sealed class ItemContentInputAudio : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
@@ -323,7 +323,7 @@ internal sealed record ItemContentInputAudio : ItemContent
/// <summary>
/// Image input content.
/// </summary>
internal sealed record ItemContentInputImage : ItemContent
internal sealed class ItemContentInputImage : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
@@ -352,7 +352,7 @@ internal sealed record ItemContentInputImage : ItemContent
/// <summary>
/// File input content.
/// </summary>
internal sealed record ItemContentInputFile : ItemContent
internal sealed class ItemContentInputFile : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
@@ -380,7 +380,7 @@ internal sealed record ItemContentInputFile : ItemContent
/// <summary>
/// Text output content.
/// </summary>
internal sealed record ItemContentOutputText : ItemContent
internal sealed class ItemContentOutputText : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
@@ -396,19 +396,19 @@ internal sealed record ItemContentOutputText : ItemContent
/// The annotations.
/// </summary>
[JsonPropertyName("annotations")]
public required IList<JsonElement> Annotations { get; init; }
public required List<JsonElement> Annotations { get; init; }
/// <summary>
/// Log probability information for the output tokens.
/// </summary>
[JsonPropertyName("logprobs")]
public IList<JsonElement> Logprobs { get; init; } = [];
public List<JsonElement> Logprobs { get; init; } = [];
}
/// <summary>
/// Audio output content.
/// </summary>
internal sealed record ItemContentOutputAudio : ItemContent
internal sealed class ItemContentOutputAudio : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
@@ -430,7 +430,7 @@ internal sealed record ItemContentOutputAudio : ItemContent
/// <summary>
/// Refusal content.
/// </summary>
internal sealed record ItemContentRefusal : ItemContent
internal sealed class ItemContentRefusal : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
@@ -448,7 +448,7 @@ internal sealed record ItemContentRefusal : ItemContent
/// <summary>
/// A file search tool call item resource.
/// </summary>
internal sealed record FileSearchToolCallItemResource : ItemResource
internal sealed class FileSearchToolCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for file search call items.
@@ -463,12 +463,24 @@ internal sealed record FileSearchToolCallItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// The queries used to search for files.
/// </summary>
[JsonPropertyName("queries")]
public List<string>? Queries { get; init; }
/// <summary>
/// The results of the file search tool call.
/// </summary>
[JsonPropertyName("results")]
public List<JsonElement>? Results { get; init; }
}
/// <summary>
/// A computer tool call item resource.
/// </summary>
internal sealed record ComputerToolCallItemResource : ItemResource
internal sealed class ComputerToolCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for computer call items.
@@ -483,12 +495,30 @@ internal sealed record ComputerToolCallItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// An identifier used when responding to the tool call with output.
/// </summary>
[JsonPropertyName("call_id")]
public string? CallId { get; init; }
/// <summary>
/// The action to perform.
/// </summary>
[JsonPropertyName("action")]
public JsonElement? Action { get; init; }
/// <summary>
/// The pending safety checks for the computer call.
/// </summary>
[JsonPropertyName("pending_safety_checks")]
public List<JsonElement>? PendingSafetyChecks { get; init; }
}
/// <summary>
/// A computer tool call output item resource.
/// </summary>
internal sealed record ComputerToolCallOutputItemResource : ItemResource
internal sealed class ComputerToolCallOutputItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for computer call output items.
@@ -503,12 +533,30 @@ internal sealed record ComputerToolCallOutputItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// The ID of the computer tool call that produced the output.
/// </summary>
[JsonPropertyName("call_id")]
public string? CallId { get; init; }
/// <summary>
/// The safety checks reported by the API that have been acknowledged by the developer.
/// </summary>
[JsonPropertyName("acknowledged_safety_checks")]
public List<JsonElement>? AcknowledgedSafetyChecks { get; init; }
/// <summary>
/// The output of the computer tool call.
/// </summary>
[JsonPropertyName("output")]
public JsonElement? Output { get; init; }
}
/// <summary>
/// A web search tool call item resource.
/// </summary>
internal sealed record WebSearchToolCallItemResource : ItemResource
internal sealed class WebSearchToolCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for web search call items.
@@ -523,12 +571,18 @@ internal sealed record WebSearchToolCallItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// An object describing the specific action taken in this web search call.
/// </summary>
[JsonPropertyName("action")]
public JsonElement? Action { get; init; }
}
/// <summary>
/// A reasoning item resource.
/// </summary>
internal sealed record ReasoningItemResource : ItemResource
internal sealed class ReasoningItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for reasoning items.
@@ -543,12 +597,25 @@ internal sealed record ReasoningItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// The encrypted content of the reasoning item - populated when a response is
/// generated with reasoning.encrypted_content in the include parameter.
/// </summary>
[JsonPropertyName("encrypted_content")]
public string? EncryptedContent { get; init; }
/// <summary>
/// Reasoning text contents.
/// </summary>
[JsonPropertyName("summary")]
public List<JsonElement>? Summary { get; init; }
}
/// <summary>
/// An item reference item resource.
/// </summary>
internal sealed record ItemReferenceItemResource : ItemResource
internal sealed class ItemReferenceItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for item reference items.
@@ -562,7 +629,7 @@ internal sealed record ItemReferenceItemResource : ItemResource
/// <summary>
/// An image generation tool call item resource.
/// </summary>
internal sealed record ImageGenerationToolCallItemResource : ItemResource
internal sealed class ImageGenerationToolCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for image generation call items.
@@ -577,12 +644,18 @@ internal sealed record ImageGenerationToolCallItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// The generated image encoded in base64.
/// </summary>
[JsonPropertyName("result")]
public string? Result { get; init; }
}
/// <summary>
/// A code interpreter tool call item resource.
/// </summary>
internal sealed record CodeInterpreterToolCallItemResource : ItemResource
internal sealed class CodeInterpreterToolCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for code interpreter call items.
@@ -597,12 +670,31 @@ internal sealed record CodeInterpreterToolCallItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// The ID of the container used to run the code.
/// </summary>
[JsonPropertyName("container_id")]
public string? ContainerId { get; init; }
/// <summary>
/// The code to run, or null if not available.
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; init; }
/// <summary>
/// The outputs generated by the code interpreter, such as logs or images.
/// Can be null if no outputs are available.
/// </summary>
[JsonPropertyName("outputs")]
public List<JsonElement>? Outputs { get; init; }
}
/// <summary>
/// A local shell tool call item resource.
/// </summary>
internal sealed record LocalShellToolCallItemResource : ItemResource
internal sealed class LocalShellToolCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for local shell call items.
@@ -617,12 +709,24 @@ internal sealed record LocalShellToolCallItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// The unique ID of the local shell tool call generated by the model.
/// </summary>
[JsonPropertyName("call_id")]
public string? CallId { get; init; }
/// <summary>
/// The action to execute.
/// </summary>
[JsonPropertyName("action")]
public JsonElement? Action { get; init; }
}
/// <summary>
/// A local shell tool call output item resource.
/// </summary>
internal sealed record LocalShellToolCallOutputItemResource : ItemResource
internal sealed class LocalShellToolCallOutputItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for local shell call output items.
@@ -637,12 +741,18 @@ internal sealed record LocalShellToolCallOutputItemResource : ItemResource
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// A JSON string of the output of the local shell tool call.
/// </summary>
[JsonPropertyName("output")]
public string? Output { get; init; }
}
/// <summary>
/// An MCP list tools item resource.
/// </summary>
internal sealed record MCPListToolsItemResource : ItemResource
internal sealed class MCPListToolsItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for MCP list tools items.
@@ -651,12 +761,30 @@ internal sealed record MCPListToolsItemResource : ItemResource
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The label of the MCP server.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
/// <summary>
/// The tools available on the server.
/// </summary>
[JsonPropertyName("tools")]
public List<JsonElement>? Tools { get; init; }
/// <summary>
/// Error message if the server could not list tools.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// An MCP approval request item resource.
/// </summary>
internal sealed record MCPApprovalRequestItemResource : ItemResource
internal sealed class MCPApprovalRequestItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for MCP approval request items.
@@ -665,12 +793,30 @@ internal sealed record MCPApprovalRequestItemResource : ItemResource
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The label of the MCP server making the request.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
/// <summary>
/// The name of the tool to run.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// A JSON string of arguments for the tool.
/// </summary>
[JsonPropertyName("arguments")]
public string? Arguments { get; init; }
}
/// <summary>
/// An MCP approval response item resource.
/// </summary>
internal sealed record MCPApprovalResponseItemResource : ItemResource
internal sealed class MCPApprovalResponseItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for MCP approval response items.
@@ -679,12 +825,30 @@ internal sealed record MCPApprovalResponseItemResource : ItemResource
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The ID of the approval request being answered.
/// </summary>
[JsonPropertyName("approval_request_id")]
public string? ApprovalRequestId { get; init; }
/// <summary>
/// Whether the request was approved.
/// </summary>
[JsonPropertyName("approve")]
public bool? Approve { get; init; }
/// <summary>
/// Optional reason for the decision.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// An MCP call item resource.
/// </summary>
internal sealed record MCPCallItemResource : ItemResource
internal sealed class MCPCallItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for MCP call items.
@@ -693,4 +857,34 @@ internal sealed record MCPCallItemResource : ItemResource
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The label of the MCP server running the tool.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
/// <summary>
/// The name of the tool that was run.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// A JSON string of the arguments passed to the tool.
/// </summary>
[JsonPropertyName("arguments")]
public string? Arguments { get; init; }
/// <summary>
/// The output from the tool call.
/// </summary>
[JsonPropertyName("output")]
public string? Output { get; init; }
/// <summary>
/// The error from the tool call, if any.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// <summary>
/// Reference to a prompt template and its variables.
/// </summary>
internal sealed record PromptReference
internal sealed class PromptReference
{
/// <summary>
/// The ID of the prompt template to use.
@@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// <summary>
/// Configuration options for reasoning models.
/// </summary>
internal sealed record ReasoningOptions
internal sealed class ReasoningOptions
{
/// <summary>
/// Constrains effort on reasoning for reasoning models.
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// The status of a response generation.
/// </summary>
[JsonConverter(typeof(SnakeCaseEnumConverter<ResponseStatus>))]
public enum ResponseStatus
internal enum ResponseStatus
{
/// <summary>
/// The response has been completed.
@@ -111,7 +111,7 @@ internal sealed record Response
/// The output items (messages) generated in the response.
/// </summary>
[JsonPropertyName("output")]
public required IList<ItemResource> Output { get; init; }
public required List<ItemResource> Output { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("tools")]
public required IList<JsonElement> Tools { get; init; }
public required List<JsonElement> Tools { get; init; }
/// <summary>
/// How the model should select which tool (or tools) to use when generating a response.
@@ -288,6 +288,9 @@ internal sealed record IncompleteDetails
/// </summary>
internal sealed record ResponseUsage
{
/// <summary>
/// Gets a zero usage instance.
/// </summary>
public static ResponseUsage Zero { get; } = new()
{
InputTokens = 0,
@@ -21,7 +21,7 @@ internal sealed class ResponseInput : IEquatable<ResponseInput>
this.Messages = null;
}
private ResponseInput(IReadOnlyList<InputMessage> messages)
private ResponseInput(List<InputMessage> messages)
{
this.Messages = messages ?? throw new ArgumentNullException(nameof(messages));
this.Text = null;
@@ -35,12 +35,12 @@ internal sealed class ResponseInput : IEquatable<ResponseInput>
/// <summary>
/// Creates a ResponseInput from a list of messages.
/// </summary>
public static ResponseInput FromMessages(IReadOnlyList<InputMessage> messages) => new(messages);
public static ResponseInput FromMessages(List<InputMessage> messages) => new(messages);
/// <summary>
/// Creates a ResponseInput from a list of messages.
/// </summary>
public static ResponseInput FromMessages(params InputMessage[] messages) => new(messages);
public static ResponseInput FromMessages(params InputMessage[] messages) => new(messages.ToList());
/// <summary>
/// Implicit conversion from string to ResponseInput.
@@ -75,12 +75,13 @@ internal sealed class ResponseInput : IEquatable<ResponseInput>
/// <summary>
/// Gets the messages value, or null if this is not a messages input.
/// </summary>
public IReadOnlyList<InputMessage>? Messages { get; }
public List<InputMessage>? Messages { get; }
/// <summary>
/// Gets the input as a list of InputMessage objects.
/// </summary>
public IReadOnlyList<InputMessage> GetInputMessages()
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Method performs transformation logic")]
public List<InputMessage> GetInputMessages()
{
if (this.Text is not null)
{
@@ -164,6 +165,7 @@ internal sealed class ResponseInput : IEquatable<ResponseInput>
/// </summary>
internal sealed class ResponseInputJsonConverter : JsonConverter<ResponseInput>
{
/// <inheritdoc/>
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<ResponseInput>
// 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}");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ResponseInput value, JsonSerializerOptions options)
{
if (value.IsText)
@@ -191,7 +194,7 @@ internal sealed class ResponseInputJsonConverter : JsonConverter<ResponseInput>
}
else if (value.IsMessages)
{
JsonSerializer.Serialize(writer, value.Messages!, ResponsesJsonContext.Default.IReadOnlyListInputMessage);
JsonSerializer.Serialize(writer, value.Messages!, OpenAIHostingJsonContext.Default.ListInputMessage);
}
else
{
@@ -7,16 +7,8 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
/// <summary>
/// Options for streaming responses. Only set this when you set stream: true.
/// </summary>
internal sealed record StreamOptions
internal sealed class StreamOptions
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("include_usage")]
public bool? IncludeUsage { get; init; }
/// <summary>
/// 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
@@ -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
{
/// <summary>
/// Gets the type identifier for the streaming response event.
@@ -43,11 +47,22 @@ internal abstract record StreamingResponseEvent
public int SequenceNumber { get; init; }
}
/// <summary>
/// Denotes an <see cref="StreamingResponseEvent"/> instance which contains an update to the <see cref="Models.Response"/> instance.
/// </summary>
internal interface IStreamingResponseEventWithResponse
{
/// <summary>
/// Gets the response object associated with this streaming event.
/// </summary>
Response Response { get; }
}
/// <summary>
/// Represents a streaming response event indicating that a new response has been created and streaming has begun.
/// This is typically the first event sent in a streaming response sequence.
/// </summary>
internal sealed record StreamingResponseCreated : StreamingResponseEvent
internal sealed class StreamingResponseCreated : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
/// <summary>
/// The constant event type identifier for response created events.
@@ -69,7 +84,7 @@ internal sealed record StreamingResponseCreated : StreamingResponseEvent
/// <summary>
/// Represents a streaming response event indicating that the response is in progress.
/// </summary>
internal sealed record StreamingResponseInProgress : StreamingResponseEvent
internal sealed class StreamingResponseInProgress : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
/// <summary>
/// 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.
/// </summary>
internal sealed record StreamingResponseCompleted : StreamingResponseEvent
internal sealed class StreamingResponseCompleted : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
/// <summary>
/// The constant event type identifier for response completed events.
@@ -113,7 +128,7 @@ internal sealed record StreamingResponseCompleted : StreamingResponseEvent
/// <summary>
/// Represents a streaming response event indicating that the response finished as incomplete.
/// </summary>
internal sealed record StreamingResponseIncomplete : StreamingResponseEvent
internal sealed class StreamingResponseIncomplete : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
/// <summary>
/// The constant event type identifier for response incomplete events.
@@ -134,7 +149,7 @@ internal sealed record StreamingResponseIncomplete : StreamingResponseEvent
/// <summary>
/// Represents a streaming response event indicating that the response has failed.
/// </summary>
internal sealed record StreamingResponseFailed : StreamingResponseEvent
internal sealed class StreamingResponseFailed : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
/// <summary>
/// The constant event type identifier for response failed events.
@@ -152,11 +167,33 @@ internal sealed record StreamingResponseFailed : StreamingResponseEvent
public required Response Response { get; init; }
}
/// <summary>
/// Represents a streaming response event indicating that the response has been cancelled.
/// Only responses created with background=true can be cancelled.
/// </summary>
internal sealed class StreamingResponseCancelled : StreamingResponseEvent, IStreamingResponseEventWithResponse
{
/// <summary>
/// The constant event type identifier for response cancelled events.
/// </summary>
public const string EventType = "response.cancelled";
/// <inheritdoc/>
[JsonIgnore]
public override string Type => EventType;
/// <summary>
/// Gets or sets the cancelled response object.
/// </summary>
[JsonPropertyName("response")]
public required Response Response { get; init; }
}
/// <summary>
/// Represents a streaming response event indicating that a new output item has been added to the response.
/// This event is sent when the AI agent produces a new piece of content during streaming.
/// </summary>
internal sealed record StreamingOutputItemAdded : StreamingResponseEvent
internal sealed class StreamingOutputItemAdded : StreamingResponseEvent
{
/// <summary>
/// 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.
/// </summary>
internal sealed record StreamingOutputItemDone : StreamingResponseEvent
internal sealed class StreamingOutputItemDone : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for output item done events.
@@ -215,7 +252,7 @@ internal sealed record StreamingOutputItemDone : StreamingResponseEvent
/// <summary>
/// Represents a streaming response event indicating that a new content part has been added to an output item.
/// </summary>
internal sealed record StreamingContentPartAdded : StreamingResponseEvent
internal sealed class StreamingContentPartAdded : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for content part added events.
@@ -254,7 +291,7 @@ internal sealed record StreamingContentPartAdded : StreamingResponseEvent
/// <summary>
/// Represents a streaming response event indicating that a content part has been completed.
/// </summary>
internal sealed record StreamingContentPartDone : StreamingResponseEvent
internal sealed class StreamingContentPartDone : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for content part done events.
@@ -293,7 +330,7 @@ internal sealed record StreamingContentPartDone : StreamingResponseEvent
/// <summary>
/// Represents a streaming response event containing a text delta (incremental text chunk).
/// </summary>
internal sealed record StreamingOutputTextDelta : StreamingResponseEvent
internal sealed class StreamingOutputTextDelta : StreamingResponseEvent
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("logprobs")]
public IList<JsonElement> Logprobs { get; init; } = [];
public List<JsonElement> Logprobs { get; init; } = [];
}
/// <summary>
/// Represents a streaming response event indicating that output text has been completed.
/// </summary>
internal sealed record StreamingOutputTextDone : StreamingResponseEvent
internal sealed class StreamingOutputTextDone : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for output text done events.
@@ -377,7 +414,7 @@ internal sealed record StreamingOutputTextDone : StreamingResponseEvent
/// <summary>
/// Represents a streaming response event containing a function call arguments delta.
/// </summary>
internal sealed record StreamingFunctionCallArgumentsDelta : StreamingResponseEvent
internal sealed class StreamingFunctionCallArgumentsDelta : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for function call arguments delta events.
@@ -410,7 +447,7 @@ internal sealed record StreamingFunctionCallArgumentsDelta : StreamingResponseEv
/// <summary>
/// Represents a streaming response event indicating that function call arguments are complete.
/// </summary>
internal sealed record StreamingFunctionCallArgumentsDone : StreamingResponseEvent
internal sealed class StreamingFunctionCallArgumentsDone : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for function call arguments done events.
@@ -443,7 +480,7 @@ internal sealed record StreamingFunctionCallArgumentsDone : StreamingResponseEve
/// <summary>
/// Represents a streaming response event containing a reasoning summary text delta (incremental text chunk).
/// </summary>
internal sealed record StreamingReasoningSummaryTextDelta : StreamingResponseEvent
internal sealed class StreamingReasoningSummaryTextDelta : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for reasoning summary text delta events.
@@ -482,7 +519,7 @@ internal sealed record StreamingReasoningSummaryTextDelta : StreamingResponseEve
/// <summary>
/// Represents a streaming response event indicating that reasoning summary text has been completed.
/// </summary>
internal sealed record StreamingReasoningSummaryTextDone : StreamingResponseEvent
internal sealed class StreamingReasoningSummaryTextDone : StreamingResponseEvent
{
/// <summary>
/// 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; }
}
/// <summary>
/// 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.
/// </summary>
internal sealed class StreamingWorkflowEventComplete : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for workflow event events.
/// </summary>
public const string EventType = "response.workflow_event.complete";
/// <inheritdoc/>
[JsonIgnore]
public override string Type => EventType;
/// <summary>
/// Gets or sets the index of the output in the response.
/// </summary>
[JsonPropertyName("output_index")]
public int OutputIndex { get; set; }
/// <summary>
/// Gets or sets the workflow event data containing event type, executor ID, and event-specific data.
/// </summary>
[JsonPropertyName("data")]
public JsonElement? Data { get; set; }
/// <summary>
/// Gets or sets the executor ID if this is an executor-scoped event.
/// </summary>
[JsonPropertyName("executor_id")]
public string? ExecutorId { get; set; }
/// <summary>
/// Gets or sets the item ID for tracking purposes.
/// </summary>
[JsonPropertyName("item_id")]
public string? ItemId { get; set; }
}
/// <summary>
/// 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.
/// </summary>
internal sealed class StreamingFunctionApprovalRequested : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for function approval requested events.
/// </summary>
public const string EventType = "response.function_approval.requested";
/// <inheritdoc/>
[JsonIgnore]
public override string Type => EventType;
/// <summary>
/// Gets or sets the unique identifier for the approval request.
/// </summary>
[JsonPropertyName("request_id")]
public required string RequestId { get; init; }
/// <summary>
/// Gets or sets the function call that requires approval.
/// </summary>
[JsonPropertyName("function_call")]
public required FunctionCallInfo FunctionCall { get; init; }
/// <summary>
/// Gets or sets the item ID for tracking purposes.
/// </summary>
[JsonPropertyName("item_id")]
public required string ItemId { get; init; }
/// <summary>
/// Gets or sets the output index.
/// </summary>
[JsonPropertyName("output_index")]
public int OutputIndex { get; init; }
}
/// <summary>
/// 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.
/// </summary>
internal sealed class StreamingFunctionApprovalResponded : StreamingResponseEvent
{
/// <summary>
/// The constant event type identifier for function approval responded events.
/// </summary>
public const string EventType = "response.function_approval.responded";
/// <inheritdoc/>
[JsonIgnore]
public override string Type => EventType;
/// <summary>
/// Gets or sets the unique identifier of the approval request being responded to.
/// </summary>
[JsonPropertyName("request_id")]
public required string RequestId { get; init; }
/// <summary>
/// Gets or sets a value indicating whether the function call was approved.
/// </summary>
[JsonPropertyName("approved")]
public bool Approved { get; init; }
/// <summary>
/// Gets or sets the item ID for tracking purposes.
/// </summary>
[JsonPropertyName("item_id")]
public required string ItemId { get; init; }
/// <summary>
/// Gets or sets the output index.
/// </summary>
[JsonPropertyName("output_index")]
public int OutputIndex { get; init; }
}
/// <summary>
/// Represents function call information for approval events.
/// </summary>
internal sealed class FunctionCallInfo
{
/// <summary>
/// Gets or sets the function call ID.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Gets or sets the function name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Gets or sets the function arguments.
/// </summary>
[JsonPropertyName("arguments")]
public required JsonElement Arguments { get; init; }
}
@@ -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;
/// <summary>
/// Configuration options for a text response from the model.
/// </summary>
internal sealed record TextConfiguration
internal sealed class TextConfiguration
{
/// <summary>
/// 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
{
/// <summary>
/// The type of response format.
@@ -46,7 +46,7 @@ internal abstract record ResponseTextFormatConfiguration
/// <summary>
/// Plain text response format configuration.
/// </summary>
internal sealed record ResponseTextFormatConfigurationText : ResponseTextFormatConfiguration
internal sealed class ResponseTextFormatConfigurationText : ResponseTextFormatConfiguration
{
/// <summary>
/// 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.
/// </summary>
internal sealed record ResponseTextFormatConfigurationJsonObject : ResponseTextFormatConfiguration
internal sealed class ResponseTextFormatConfigurationJsonObject : ResponseTextFormatConfiguration
{
/// <summary>
/// Gets the type of response format. Always "json_object".
@@ -71,7 +71,7 @@ internal sealed record ResponseTextFormatConfigurationJsonObject : ResponseTextF
/// <summary>
/// JSON schema response format configuration with structured output schema.
/// </summary>
internal sealed record ResponseTextFormatConfigurationJsonSchema : ResponseTextFormatConfiguration
internal sealed class ResponseTextFormatConfigurationJsonSchema : ResponseTextFormatConfiguration
{
/// <summary>
/// Gets the type of response format. Always "json_schema".
@@ -97,7 +97,7 @@ internal sealed record ResponseTextFormatConfigurationJsonSchema : ResponseTextF
/// The JSON schema for structured outputs.
/// </summary>
[JsonPropertyName("schema")]
public required Dictionary<string, object> Schema { get; init; }
public required JsonElement Schema { get; init; }
/// <summary>
/// Whether to enable strict schema adherence when generating the output.
@@ -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;
/// <summary>
/// Represents workflow event data for serialization.
/// </summary>
internal sealed class WorkflowEventData
{
/// <summary>
/// The type of the workflow event.
/// </summary>
[JsonPropertyName("event_type")]
public required string EventType { get; init; }
/// <summary>
/// The event data payload.
/// </summary>
[JsonPropertyName("data")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Data { get; init; }
/// <summary>
/// The executor ID, if this is an executor event.
/// </summary>
[JsonPropertyName("executor_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ExecutorId { get; init; }
/// <summary>
/// The timestamp when the event occurred.
/// </summary>
[JsonPropertyName("timestamp")]
public required string Timestamp { get; init; }
}
@@ -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;
/// <summary>
/// Handles route requests for OpenAI Responses API endpoints.
/// </summary>
internal sealed class ResponsesHttpHandler
{
private readonly IResponsesService _responsesService;
/// <summary>
/// Initializes a new instance of the <see cref="ResponsesHttpHandler"/> class.
/// </summary>
/// <param name="responsesService">The responses service.</param>
public ResponsesHttpHandler(IResponsesService responsesService)
{
this._responsesService = responsesService ?? throw new ArgumentNullException(nameof(responsesService));
}
/// <summary>
/// Creates a model response for the given input.
/// </summary>
public async Task<IResult> 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<StreamingResponseEvent>(
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"
}
});
}
}
/// <summary>
/// Retrieves a response by ID.
/// </summary>
public async Task<IResult> 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<StreamingResponseEvent>(
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"
}
});
}
/// <summary>
/// Cancels an in-progress response.
/// </summary>
public async Task<IResult> 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"
}
});
}
}
/// <summary>
/// Deletes a response.
/// </summary>
public async Task<IResult> 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"
}
});
}
/// <summary>
/// Lists the input items for a response.
/// </summary>
public async Task<IResult> 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"
}
});
}
}
}
@@ -1,24 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
/// <summary>
/// Extension methods for JSON serialization.
/// </summary>
internal static class ResponsesJsonSerializerOptions
{
/// <summary>
/// Gets the default JSON serializer options.
/// </summary>
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;
}
}
@@ -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<StreamingResponseEvent> 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<StreamingResponseEvent> Complete()
{
this._isCompleted = true;
return [];
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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<StreamingResponseEvent> 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<StreamingResponseEvent> Complete()
{
this._isCompleted = true;
return [];
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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<StreamingResponseEvent> 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<StreamingResponseEvent> Complete()
{
this._isCompleted = true;
return [];
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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;
/// <summary>
/// A generator for streaming events from function approval request content.
/// This is a non-standard DevUI extension for human-in-the-loop scenarios.
/// </summary>
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<StreamingResponseEvent> 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<string, object>)))
}
};
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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;
/// <summary>
/// A generator for streaming events from function approval response content.
/// This is a non-standard DevUI extension for human-in-the-loop scenarios.
/// </summary>
internal sealed class FunctionApprovalResponseEventGenerator(
IdGenerator idGenerator,
SequenceNumber seq,
int outputIndex) : StreamingEventGenerator
{
public override bool IsSupported(AIContent content) => content is FunctionApprovalResponseContent;
public override IEnumerable<StreamingResponseEvent> 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<StreamingResponseEvent> Complete() => [];
}
@@ -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<StreamingResponseEvent> 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<StreamingResponseEvent> Complete()
{
this._isCompleted = true;
return [];
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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<StreamingResponseEvent> 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<StreamingResponseEvent> Complete()
{
this._isCompleted = true;
return [];
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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<StreamingResponseEvent> 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<StreamingResponseEvent> Complete()
{
this._isCompleted = true;
return [];
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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<StreamingResponseEvent> 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<StreamingResponseEvent> Complete()
{
this._isCompleted = true;
return [];
}
public override IEnumerable<StreamingResponseEvent> Complete() => [];
}
@@ -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
@@ -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
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// Uses the in-memory responses service implementation.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
@@ -36,8 +40,39 @@ public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.Configure<JsonOptions>(options => options.SerializerOptions.TypeInfoResolverChain.Add(ResponsesJsonSerializerOptions.Default.TypeInfoResolver!));
services.Configure<JsonOptions>(options
=> options.SerializerOptions.TypeInfoResolverChain.Add(
OpenAIHostingJsonContext.Default.Options.TypeInfoResolver!));
services.TryAddSingleton<IConversationStorage, InMemoryConversationStorage>();
services.TryAddSingleton<IAgentConversationIndex, InMemoryAgentConversationIndex>();
services.TryAddSingleton<InMemoryStorageOptions>();
services.TryAddSingleton<IResponsesService>(sp =>
{
var executor = sp.GetRequiredService<IResponseExecutor>();
var options = sp.GetRequiredService<InMemoryStorageOptions>();
var conversationStorage = sp.GetService<IConversationStorage>();
return new InMemoryResponsesService(executor, options, conversationStorage);
});
services.TryAddSingleton<IResponseExecutor, HostedAgentResponseExecutor>();
return services;
}
/// <summary>
/// Adds in-memory conversation storage and indexing services to the service collection.
/// This is suitable only for development and testing scenarios.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddOpenAIConversations(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Register storage options
services.TryAddSingleton<InMemoryStorageOptions>();
services.TryAddSingleton<IConversationStorage, InMemoryConversationStorage>();
services.TryAddSingleton<IAgentConversationIndex, InMemoryAgentConversationIndex>();
return services;
}
}
@@ -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;
/// <summary>
/// IResult implementation for streaming JSON data using Server-Sent Events (SSE).
/// </summary>
/// <typeparam name="T">The type of items to stream.</typeparam>
internal sealed class SseJsonResult<T> : IResult
{
private readonly IAsyncEnumerable<T> _events;
private readonly JsonTypeInfo<T> _jsonTypeInfo;
private readonly Func<T, string?> _getEventType;
/// <summary>
/// Initializes a new instance of the <see cref="SseJsonResult{T}"/> class.
/// </summary>
/// <param name="events">The async enumerable of items to stream.</param>
/// <param name="getEventType">A function to get the optional event type from each item.</param>
/// <param name="jsonTypeInfo">The JSON type information for serializing items.</param>
public SseJsonResult(IAsyncEnumerable<T> events, Func<T, string?> getEventType, JsonTypeInfo<T> jsonTypeInfo)
{
this._events = events ?? throw new ArgumentNullException(nameof(events));
this._jsonTypeInfo = jsonTypeInfo ?? throw new ArgumentNullException(nameof(jsonTypeInfo));
this._getEventType = getEventType ?? throw new ArgumentNullException(nameof(getEventType));
}
/// <summary>
/// Executes the result by streaming items to the HTTP response using Server-Sent Events format.
/// </summary>
/// <param name="httpContext">The HTTP context.</param>
public async Task ExecuteAsync(HttpContext httpContext)
{
var response = httpContext.Response;
var cancellationToken = httpContext.RequestAborted;
// Set SSE headers
response.Headers.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache,no-store";
response.Headers.Connection = "keep-alive";
response.Headers.ContentEncoding = "identity";
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
await SseFormatter.WriteAsync(
source: this.GetItemsAsync(),
destination: response.Body,
itemFormatter: this.FormatItem,
cancellationToken).ConfigureAwait(false);
}
private async IAsyncEnumerable<SseItem<T>> GetItemsAsync()
{
await foreach (var item in this._events.ConfigureAwait(false))
{
yield return new SseItem<T>(item, this._getEventType(item));
}
}
private void FormatItem(SseItem<T> sseItem, IBufferWriter<byte> bufferWriter)
{
using var writer = new Utf8JsonWriter(bufferWriter);
JsonSerializer.Serialize(writer, sseItem.Data, this._jsonTypeInfo);
writer.Flush();
}
}