mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
e2282ebe42
commit
33f84f9ed2
+1
-1
@@ -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))
|
||||
{
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+9
-10
@@ -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)
|
||||
|
||||
+342
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+121
@@ -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();
|
||||
}
|
||||
}
|
||||
+346
@@ -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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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; } = [];
|
||||
}
|
||||
+26
@@ -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; }
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+73
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
-21
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+76
-11
@@ -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;
|
||||
}
|
||||
|
||||
+99
-35
@@ -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>
|
||||
|
||||
+81
-25
@@ -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: "")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+30
-28
@@ -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
|
||||
|
||||
+56
@@ -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()));
|
||||
}
|
||||
}
|
||||
+42
-79
@@ -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}");
|
||||
|
||||
+59
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-50
@@ -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}");
|
||||
|
||||
+3
-2
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
+115
@@ -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").
|
||||
|
||||
+5
-3
@@ -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.
|
||||
|
||||
+20
-6
@@ -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; }
|
||||
}
|
||||
+157
@@ -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
|
||||
|
||||
+199
-17
@@ -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"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
-24
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-17
@@ -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() => [];
|
||||
}
|
||||
|
||||
+2
-17
@@ -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() => [];
|
||||
}
|
||||
|
||||
+2
-17
@@ -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() => [];
|
||||
}
|
||||
|
||||
+48
@@ -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() => [];
|
||||
}
|
||||
+39
@@ -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() => [];
|
||||
}
|
||||
+1
-14
@@ -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() => [];
|
||||
}
|
||||
|
||||
+1
-14
@@ -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() => [];
|
||||
}
|
||||
|
||||
+2
-17
@@ -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() => [];
|
||||
}
|
||||
|
||||
+4
-19
@@ -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() => [];
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user