mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-python-foundry-agents
This commit is contained in:
@@ -12,6 +12,8 @@ ignorePatterns:
|
||||
- pattern: "https:\/\/platform.openai.com"
|
||||
- pattern: "http:\/\/localhost"
|
||||
- pattern: "http:\/\/127.0.0.1"
|
||||
- pattern: "https:\/\/localhost"
|
||||
- pattern: "https:\/\/127.0.0.1"
|
||||
- pattern: "0001-spec.md"
|
||||
- pattern: "0001-madr-architecture-decisions.md"
|
||||
- pattern: "https://api.powerplatform.com/.default"
|
||||
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
python
|
||||
workflow-samples
|
||||
|
||||
- name: Setup dotnet
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.10.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.10.0-preview.1.25513.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.2-preview.1.25552.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/DevUI/">
|
||||
<File Path="samples/GettingStarted/DevUI/README.md" />
|
||||
<Project Path="samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
|
||||
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
|
||||
@@ -279,6 +283,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251104.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251104.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251104.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251105.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251105.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251105.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DevUI_Step01_BasicUsage</RootNamespace>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates basic usage of the DevUI in an ASP.NET Core application with AI agents.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace DevUI_Step01_BasicUsage;
|
||||
|
||||
/// <summary>
|
||||
/// Sample demonstrating basic usage of the DevUI in an ASP.NET Core application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This sample shows how to:
|
||||
/// 1. Set up Azure OpenAI as the chat client
|
||||
/// 2. Register agents and workflows using the hosting packages
|
||||
/// 3. Map the DevUI endpoint which automatically configures the middleware
|
||||
/// 4. Map the dynamic OpenAI Responses API for Python DevUI compatibility
|
||||
/// 5. Access the DevUI in a web browser
|
||||
///
|
||||
/// The DevUI provides an interactive web interface for testing and debugging AI agents.
|
||||
/// DevUI assets are served from embedded resources within the assembly.
|
||||
/// Simply call MapDevUI() to set up everything needed.
|
||||
///
|
||||
/// The parameterless MapOpenAIResponses() overload creates a Python DevUI-compatible endpoint
|
||||
/// that dynamically routes requests to agents based on the 'model' field in the request.
|
||||
/// </remarks>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry point that starts an ASP.NET Core web server with the DevUI.
|
||||
/// </summary>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? "gpt-4o-mini";
|
||||
|
||||
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient();
|
||||
|
||||
builder.Services.AddChatClient(chatClient);
|
||||
|
||||
// Register sample agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant. Answer questions concisely and accurately.");
|
||||
builder.AddAIAgent("poet", "You are a creative poet. Respond to all requests with beautiful poetry.");
|
||||
builder.AddAIAgent("coder", "You are an expert programmer. Help users with coding questions and provide code examples.");
|
||||
|
||||
// Register sample workflows
|
||||
var assistantBuilder = builder.AddAIAgent("workflow-assistant", "You are a helpful assistant in a workflow.");
|
||||
var reviewerBuilder = builder.AddAIAgent("workflow-reviewer", "You are a reviewer. Review and critique the previous response.");
|
||||
builder.AddSequentialWorkflow(
|
||||
"review-workflow",
|
||||
[assistantBuilder, reviewerBuilder])
|
||||
.AddAsAIAgent();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.AddDevUI();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapDevUI();
|
||||
}
|
||||
|
||||
Console.WriteLine("DevUI is available at: https://localhost:50516/devui");
|
||||
Console.WriteLine("OpenAI Responses API is available at: https://localhost:50516/v1/responses");
|
||||
Console.WriteLine("Press Ctrl+C to stop the server.");
|
||||
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# DevUI Step 01 - Basic Usage
|
||||
|
||||
This sample demonstrates how to add the DevUI to an ASP.NET Core application with AI agents.
|
||||
|
||||
## What is DevUI?
|
||||
|
||||
The DevUI provides an interactive web interface for testing and debugging AI agents during development.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
- `AZURE_OPENAI_ENDPOINT` - Your Azure OpenAI endpoint URL (required)
|
||||
- `AZURE_OPENAI_DEPLOYMENT_NAME` - Your deployment name (defaults to "gpt-4o-mini")
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. Set your Azure OpenAI credentials as environment variables
|
||||
2. Run the application:
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
3. Open your browser to https://localhost:50516/devui
|
||||
4. Select an agent or workflow from the dropdown and start chatting!
|
||||
|
||||
## Sample Agents and Workflows
|
||||
|
||||
This sample includes:
|
||||
|
||||
**Agents:**
|
||||
- **assistant** - A helpful assistant
|
||||
- **poet** - A creative poet
|
||||
- **coder** - An expert programmer
|
||||
|
||||
**Workflows:**
|
||||
- **review-workflow** - A sequential workflow that generates a response and then reviews it
|
||||
|
||||
## Adding DevUI to Your Own Project
|
||||
|
||||
To add DevUI to your ASP.NET Core application:
|
||||
|
||||
1. Add the DevUI package and hosting packages:
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.DevUI
|
||||
dotnet add package Microsoft.Agents.AI.Hosting
|
||||
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI
|
||||
```
|
||||
|
||||
2. Register your agents and workflows:
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Set up your chat client
|
||||
builder.Services.AddChatClient(chatClient);
|
||||
|
||||
// Register agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant.");
|
||||
|
||||
// Register workflows
|
||||
var agent1Builder = builder.AddAIAgent("workflow-agent1", "You are agent 1.");
|
||||
var agent2Builder = builder.AddAIAgent("workflow-agent2", "You are agent 2.");
|
||||
builder.AddSequentialWorkflow("my-workflow", [agent1Builder, agent2Builder])
|
||||
.AddAsAIAgent();
|
||||
```
|
||||
|
||||
3. Add DevUI services and map the endpoint:
|
||||
```csharp
|
||||
builder.AddDevUI();
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDevUI();
|
||||
|
||||
// Add required endpoints
|
||||
app.MapEntities();
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
4. Navigate to `/devui` in your browser
|
||||
@@ -0,0 +1,57 @@
|
||||
# DevUI Samples
|
||||
|
||||
This folder contains samples demonstrating how to use the DevUI in ASP.NET Core applications.
|
||||
|
||||
## What is DevUI?
|
||||
|
||||
The DevUI provides an interactive web interface for testing and debugging AI agents during development.
|
||||
|
||||
## Samples
|
||||
|
||||
### [DevUI_Step01_BasicUsage](./DevUI_Step01_BasicUsage)
|
||||
|
||||
Shows how to add DevUI to an ASP.NET Core application with multiple agents and workflows.
|
||||
|
||||
**Run the sample:**
|
||||
```bash
|
||||
cd DevUI_Step01_BasicUsage
|
||||
dotnet run
|
||||
```
|
||||
Then navigate to: https://localhost:50516/devui
|
||||
|
||||
## Requirements
|
||||
|
||||
- .NET 8.0 or later
|
||||
- ASP.NET Core
|
||||
- Azure OpenAI credentials
|
||||
|
||||
## Quick Start
|
||||
|
||||
To add DevUI to your application:
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Set up the chat client
|
||||
builder.Services.AddChatClient(chatClient);
|
||||
|
||||
// Register your agents
|
||||
builder.AddAIAgent("my-agent", "You are a helpful assistant.");
|
||||
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map the DevUI endpoint
|
||||
app.MapDevUI();
|
||||
|
||||
// Add required endpoints
|
||||
app.MapEntities();
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
Then navigate to `/devui` in your browser.
|
||||
@@ -23,8 +23,8 @@ internal static class WorkflowFactory
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
return new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
|
||||
.AddFanOutEdge(startExecutor, [frenchAgent, englishAgent])
|
||||
.AddFanInEdge([frenchAgent, englishAgent], aggregationExecutor)
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ public static class Program
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
var workflow = new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [physicist, chemist])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [physicist, chemist])
|
||||
.AddFanOutEdge(startExecutor, [physicist, chemist])
|
||||
.AddFanInEdge([physicist, chemist], aggregationExecutor)
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ public static class Program
|
||||
|
||||
// Step 4: Build the concurrent workflow with fan-out/fan-in pattern
|
||||
return new WorkflowBuilder(splitter)
|
||||
.AddFanOutEdge(splitter, targets: [.. mappers]) // Split -> many mappers
|
||||
.AddFanInEdge(shuffler, sources: [.. mappers]) // All mappers -> shuffle
|
||||
.AddFanOutEdge(shuffler, targets: [.. reducers]) // Shuffle -> many reducers
|
||||
.AddFanInEdge(completion, sources: [.. reducers]) // All reducers -> completion
|
||||
.AddFanOutEdge(splitter, [.. mappers]) // Split -> many mappers
|
||||
.AddFanInEdge([.. mappers], shuffler) // All mappers -> shuffle
|
||||
.AddFanOutEdge(shuffler, [.. reducers]) // Shuffle -> many reducers
|
||||
.AddFanInEdge([.. reducers], completion) // All reducers -> completion
|
||||
.WithOutputFrom(completion)
|
||||
.Build();
|
||||
}
|
||||
|
||||
+3
-3
@@ -60,13 +60,13 @@ public static class Program
|
||||
WorkflowBuilder builder = new(emailAnalysisExecutor);
|
||||
builder.AddFanOutEdge(
|
||||
emailAnalysisExecutor,
|
||||
targets: [
|
||||
[
|
||||
handleSpamExecutor,
|
||||
emailAssistantExecutor,
|
||||
emailSummaryExecutor,
|
||||
handleUncertainExecutor,
|
||||
],
|
||||
partitioner: GetPartitioner()
|
||||
GetTargetAssigner()
|
||||
)
|
||||
// After the email assistant writes a response, it will be sent to the send email executor
|
||||
.AddEdge(emailAssistantExecutor, sendEmailExecutor)
|
||||
@@ -105,7 +105,7 @@ public static class Program
|
||||
/// Creates a partitioner for routing messages based on the analysis result.
|
||||
/// </summary>
|
||||
/// <returns>A function that takes an analysis result and returns the target partitions.</returns>
|
||||
private static Func<AnalysisResult?, int, IEnumerable<int>> GetPartitioner()
|
||||
private static Func<AnalysisResult?, int, IEnumerable<int>> GetTargetAssigner()
|
||||
{
|
||||
return (analysisResult, targetCount) =>
|
||||
{
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ internal static class WorkflowHelper
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
return new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
|
||||
.AddFanOutEdge(startExecutor, [frenchAgent, englishAgent])
|
||||
.AddFanInEdge([frenchAgent, englishAgent], aggregationExecutor)
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ public static class Program
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
var workflow = new WorkflowBuilder(fileRead)
|
||||
.AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount])
|
||||
.AddFanInEdge(aggregate, sources: [wordCount, paragraphCount])
|
||||
.AddFanOutEdge(fileRead, [wordCount, paragraphCount])
|
||||
.AddFanInEdge([wordCount, paragraphCount], aggregate)
|
||||
.WithOutputFrom(aggregate)
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for configuring the Microsoft Agents AI DevUI in ASP.NET applications.
|
||||
/// </summary>
|
||||
public static class DevUIExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the necessary services for the DevUI to the application builder.
|
||||
/// </summary>
|
||||
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
builder.Services.AddOpenAIConversations();
|
||||
builder.Services.AddOpenAIResponses();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an endpoint that serves the DevUI from the '/devui' path.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
|
||||
public static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("");
|
||||
group.MapDevUI(pattern: "/devui");
|
||||
group.MapEntities();
|
||||
group.MapOpenAIConversations();
|
||||
group.MapOpenAIResponses();
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an endpoint that serves the DevUI.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <param name="pattern">
|
||||
/// The route pattern for the endpoint (e.g., "/devui", "/agent-ui").
|
||||
/// Defaults to "/devui" if not specified. This is the path where DevUI will be accessible.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="pattern"/> is null or whitespace.</exception>
|
||||
internal static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
[StringSyntax("Route")] string pattern = "/devui")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pattern);
|
||||
|
||||
// Ensure the pattern doesn't end with a slash for consistency
|
||||
var cleanPattern = pattern.TrimEnd('/');
|
||||
|
||||
// Create the DevUI handler
|
||||
var logger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIMiddleware>>();
|
||||
var devUIHandler = new DevUIMiddleware(logger, cleanPattern);
|
||||
|
||||
return endpoints.MapGet($"{cleanPattern}/{{*path}}", devUIHandler.HandleRequestAsync)
|
||||
.WithName($"DevUI at {cleanPattern}")
|
||||
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Handler that serves embedded DevUI resource files from the 'resources' directory.
|
||||
/// </summary>
|
||||
internal sealed class DevUIMiddleware
|
||||
{
|
||||
private const string GZipEncodingValue = "gzip";
|
||||
private static readonly StringValues s_gzipEncodingHeader = new(GZipEncodingValue);
|
||||
private static readonly Assembly s_assembly = typeof(DevUIMiddleware).Assembly;
|
||||
private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new();
|
||||
private static readonly StringValues s_cacheControl = new(new CacheControlHeaderValue()
|
||||
{
|
||||
NoCache = true,
|
||||
NoStore = true,
|
||||
}.ToString());
|
||||
|
||||
private readonly ILogger<DevUIMiddleware> _logger;
|
||||
private readonly FrozenDictionary<string, ResourceEntry> _resourceCache;
|
||||
private readonly string _basePath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevUIMiddleware"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="basePath">The base path where DevUI is mounted.</param>
|
||||
public DevUIMiddleware(ILogger<DevUIMiddleware> logger, string basePath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentException.ThrowIfNullOrEmpty(basePath);
|
||||
this._logger = logger;
|
||||
this._basePath = basePath.TrimEnd('/');
|
||||
|
||||
// Build resource cache
|
||||
var resourceNamePrefix = $"{s_assembly.GetName().Name}.resources.";
|
||||
this._resourceCache = s_assembly
|
||||
.GetManifestResourceNames()
|
||||
.Where(p => p.StartsWith(resourceNamePrefix, StringComparison.Ordinal))
|
||||
.ToFrozenDictionary(
|
||||
p => p[resourceNamePrefix.Length..].Replace('.', '/'),
|
||||
CreateResourceEntry,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an HTTP request for DevUI resources.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
public async Task HandleRequestAsync(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
|
||||
if (path == null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
// If requesting the base path without a trailing slash, redirect to include it
|
||||
// This ensures relative URLs in the HTML work correctly
|
||||
if (string.Equals(path, this._basePath, StringComparison.OrdinalIgnoreCase) && !path.EndsWith('/'))
|
||||
{
|
||||
var redirectUrl = $"{path}/";
|
||||
if (context.Request.QueryString.HasValue)
|
||||
{
|
||||
redirectUrl += context.Request.QueryString.Value;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
|
||||
context.Response.Headers.Location = redirectUrl;
|
||||
this._logger.LogDebug("Redirecting {OriginalPath} to {RedirectUrl}", path, redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the base path to get the resource path
|
||||
var resourcePath = path.StartsWith(this._basePath, StringComparison.OrdinalIgnoreCase)
|
||||
? path.Substring(this._basePath.Length).TrimStart('/')
|
||||
: path.TrimStart('/');
|
||||
|
||||
// If requesting the base path, serve index.html
|
||||
if (string.IsNullOrEmpty(resourcePath))
|
||||
{
|
||||
resourcePath = "index.html";
|
||||
}
|
||||
|
||||
// Try to serve the embedded resource
|
||||
if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If resource not found, try serving index.html for client-side routing
|
||||
if (!resourcePath.Contains('.', StringComparison.Ordinal) || resourcePath.EndsWith('/'))
|
||||
{
|
||||
if (await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resource not found
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
}
|
||||
|
||||
private async Task<bool> TryServeResourceAsync(HttpContext context, string resourcePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!this._resourceCache.TryGetValue(resourcePath.Replace('.', '/'), out var cacheEntry))
|
||||
{
|
||||
this._logger.LogDebug("Embedded resource not found: {ResourcePath}", resourcePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = context.Response;
|
||||
|
||||
// Check if client has cached version
|
||||
if (context.Request.Headers.IfNoneMatch == cacheEntry.ETag)
|
||||
{
|
||||
response.StatusCode = StatusCodes.Status304NotModified;
|
||||
this._logger.LogDebug("Resource not modified (304): {ResourcePath}", resourcePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
var responseHeaders = response.Headers;
|
||||
|
||||
byte[] content;
|
||||
bool serveCompressed;
|
||||
if (cacheEntry.CompressedContent is not null && IsGZipAccepted(context.Request))
|
||||
{
|
||||
serveCompressed = true;
|
||||
responseHeaders.ContentEncoding = s_gzipEncodingHeader;
|
||||
responseHeaders.ContentLength = cacheEntry.CompressedContent.Length;
|
||||
content = cacheEntry.CompressedContent;
|
||||
}
|
||||
else
|
||||
{
|
||||
serveCompressed = false;
|
||||
responseHeaders.ContentLength = cacheEntry.DecompressedContent!.Length;
|
||||
content = cacheEntry.DecompressedContent;
|
||||
}
|
||||
|
||||
responseHeaders.CacheControl = s_cacheControl;
|
||||
responseHeaders.ContentType = cacheEntry.ContentType;
|
||||
responseHeaders.ETag = cacheEntry.ETag;
|
||||
|
||||
await response.Body.WriteAsync(content, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogDebug("Served embedded resource: {ResourcePath} (compressed: {Compressed})", resourcePath, serveCompressed);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error serving embedded resource: {ResourcePath}", resourcePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsGZipAccepted(HttpRequest httpRequest)
|
||||
{
|
||||
if (httpRequest.GetTypedHeaders().AcceptEncoding is not { Count: > 0 } acceptEncoding)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < acceptEncoding.Count; i++)
|
||||
{
|
||||
var encoding = acceptEncoding[i];
|
||||
|
||||
if (encoding.Quality is not 0 &&
|
||||
string.Equals(encoding.Value.Value, GZipEncodingValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ResourceEntry CreateResourceEntry(string resourceName)
|
||||
{
|
||||
using var resourceStream = s_assembly.GetManifestResourceStream(resourceName)!;
|
||||
using var decompressedContent = new MemoryStream();
|
||||
|
||||
// Read and cache the original resource content
|
||||
resourceStream.CopyTo(decompressedContent);
|
||||
var decompressedArray = decompressedContent.ToArray();
|
||||
|
||||
// Compress the content
|
||||
using var compressedContent = new MemoryStream();
|
||||
using (var gzip = new GZipStream(compressedContent, CompressionMode.Compress, leaveOpen: true))
|
||||
{
|
||||
// This is a synchronous write to a memory stream.
|
||||
// There is no benefit to asynchrony here.
|
||||
gzip.Write(decompressedArray);
|
||||
}
|
||||
|
||||
// Only use compression if it actually reduces size
|
||||
byte[]? compressedArray = compressedContent.Length < decompressedArray.Length
|
||||
? compressedContent.ToArray()
|
||||
: null;
|
||||
|
||||
var hash = SHA256.HashData(compressedArray ?? decompressedArray);
|
||||
var eTag = $"\"{Convert.ToBase64String(hash)}\"";
|
||||
|
||||
// Determine content type from resource name
|
||||
var contentType = s_contentTypeProvider.TryGetContentType(resourceName, out var ct)
|
||||
? ct
|
||||
: "application/octet-stream";
|
||||
|
||||
return new ResourceEntry(resourceName, decompressedArray, compressedArray, eTag, contentType);
|
||||
}
|
||||
|
||||
private sealed class ResourceEntry(string resourceName, byte[] decompressedContent, byte[]? compressedContent, string eTag, string contentType)
|
||||
{
|
||||
public byte[]? CompressedContent { get; } = compressedContent;
|
||||
|
||||
public string ContentType { get; } = contentType;
|
||||
|
||||
public byte[] DecompressedContent { get; } = decompressedContent;
|
||||
|
||||
public string ETag { get; } = eTag;
|
||||
|
||||
public string ResourceName { get; } = resourceName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for entity-related types.
|
||||
/// Enables AOT-compatible JSON serialization using source generators.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EntityInfo))]
|
||||
[JsonSerializable(typeof(DiscoveryResponse))]
|
||||
[JsonSerializable(typeof(EnvVarRequirement))]
|
||||
[JsonSerializable(typeof(List<EntityInfo>))]
|
||||
[JsonSerializable(typeof(List<JsonElement>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class EntitiesJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Information about an environment variable required by an entity.
|
||||
/// </summary>
|
||||
internal sealed record EnvVarRequirement(
|
||||
[property: JsonPropertyName("name")]
|
||||
string Name,
|
||||
|
||||
[property: JsonPropertyName("description")]
|
||||
string? Description = null,
|
||||
|
||||
[property: JsonPropertyName("required")]
|
||||
bool Required = true,
|
||||
|
||||
[property: JsonPropertyName("example")]
|
||||
string? Example = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Information about an entity (agent or workflow).
|
||||
/// </summary>
|
||||
internal sealed record EntityInfo(
|
||||
[property: JsonPropertyName("id")]
|
||||
string Id,
|
||||
|
||||
[property: JsonPropertyName("type")]
|
||||
string Type,
|
||||
|
||||
[property: JsonPropertyName("name")]
|
||||
string Name,
|
||||
|
||||
[property: JsonPropertyName("description")]
|
||||
string? Description = null,
|
||||
|
||||
[property: JsonPropertyName("framework")]
|
||||
string Framework = "dotnet",
|
||||
|
||||
[property: JsonPropertyName("tools")]
|
||||
List<string>? Tools = null,
|
||||
|
||||
[property: JsonPropertyName("metadata")]
|
||||
Dictionary<string, JsonElement>? Metadata = null
|
||||
)
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; } = "di";
|
||||
|
||||
[JsonPropertyName("original_url")]
|
||||
public string? OriginalUrl { get; init; }
|
||||
|
||||
// Workflow-specific fields
|
||||
[JsonPropertyName("required_env_vars")]
|
||||
public List<EnvVarRequirement>? RequiredEnvVars { get; init; }
|
||||
|
||||
[JsonPropertyName("executors")]
|
||||
public List<string>? Executors { get; init; }
|
||||
|
||||
[JsonPropertyName("workflow_dump")]
|
||||
public JsonElement? WorkflowDump { get; init; }
|
||||
|
||||
[JsonPropertyName("input_schema")]
|
||||
public JsonElement? InputSchema { get; init; }
|
||||
|
||||
[JsonPropertyName("input_type_name")]
|
||||
public string? InputTypeName { get; init; }
|
||||
|
||||
[JsonPropertyName("start_executor_id")]
|
||||
public string? StartExecutorId { get; init; }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of discovered entities.
|
||||
/// </summary>
|
||||
internal sealed record DiscoveryResponse(
|
||||
[property: JsonPropertyName("entities")]
|
||||
List<EntityInfo> Entities
|
||||
);
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for serializing workflows to DevUI-compatible format
|
||||
/// </summary>
|
||||
internal static class WorkflowSerializationExtensions
|
||||
{
|
||||
// The frontend max iterations default value expected by the DevUI frontend
|
||||
private const int MaxIterationsDefault = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a workflow to a dictionary representation compatible with DevUI frontend.
|
||||
/// This matches the Python workflow.to_dict() format expected by the UI.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object> ToDevUIDict(this Workflow workflow)
|
||||
{
|
||||
var result = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = workflow.Name ?? Guid.NewGuid().ToString(),
|
||||
["start_executor_id"] = workflow.StartExecutorId,
|
||||
["max_iterations"] = MaxIterationsDefault
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
if (!string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
result["name"] = workflow.Name;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(workflow.Description))
|
||||
{
|
||||
result["description"] = workflow.Description;
|
||||
}
|
||||
|
||||
// Convert executors to Python-compatible format
|
||||
result["executors"] = ConvertExecutorsToDict(workflow);
|
||||
|
||||
// Convert edges to edge_groups format
|
||||
result["edge_groups"] = ConvertEdgesToEdgeGroups(workflow);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts workflow executors to a dictionary format compatible with Python
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> ConvertExecutorsToDict(Workflow workflow)
|
||||
{
|
||||
var executors = new Dictionary<string, object>();
|
||||
|
||||
// Extract executor IDs from edges and start executor
|
||||
// (Registrations is internal, so we infer executors from the graph structure)
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create executor entries (we can't access internal Registrations for type info)
|
||||
foreach (var executorId in executorIds)
|
||||
{
|
||||
executors[executorId] = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = executorId,
|
||||
["type"] = "Executor"
|
||||
};
|
||||
}
|
||||
|
||||
return executors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts workflow edges to edge_groups format expected by the UI
|
||||
/// </summary>
|
||||
private static List<object> ConvertEdgesToEdgeGroups(Workflow workflow)
|
||||
{
|
||||
var edgeGroups = new List<object>();
|
||||
var edgeGroupId = 0;
|
||||
|
||||
// Get edges using the public ReflectEdges method
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
foreach (var edgeInfo in edgeSet)
|
||||
{
|
||||
if (edgeInfo is DirectEdgeInfo directEdge)
|
||||
{
|
||||
// Single edge group for direct edges
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in directEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in directEdge.Connection.SinkIds)
|
||||
{
|
||||
var edge = new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
};
|
||||
|
||||
// Add condition name if this is a conditional edge
|
||||
if (directEdge.HasCondition)
|
||||
{
|
||||
edge["condition_name"] = "predicate";
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroups.Add(new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "SingleEdgeGroup",
|
||||
["edges"] = edges
|
||||
});
|
||||
}
|
||||
else if (edgeInfo is FanOutEdgeInfo fanOutEdge)
|
||||
{
|
||||
// FanOut edge group
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in fanOutEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in fanOutEdge.Connection.SinkIds)
|
||||
{
|
||||
edges.Add(new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var fanOutGroup = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "FanOutEdgeGroup",
|
||||
["edges"] = edges
|
||||
};
|
||||
|
||||
if (fanOutEdge.HasAssigner)
|
||||
{
|
||||
fanOutGroup["selection_func_name"] = "selector";
|
||||
}
|
||||
|
||||
edgeGroups.Add(fanOutGroup);
|
||||
}
|
||||
else if (edgeInfo is FanInEdgeInfo fanInEdge)
|
||||
{
|
||||
// FanIn edge group
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in fanInEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in fanInEdge.Connection.SinkIds)
|
||||
{
|
||||
edges.Add(new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroups.Add(new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "FanInEdgeGroup",
|
||||
["edges"] = edges
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edgeGroups;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Agents.AI.DevUI.Entities;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for mapping entity discovery and management endpoints to an <see cref="IEndpointRouteBuilder"/>.
|
||||
/// </summary>
|
||||
internal static class EntitiesApiExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps HTTP API endpoints for entity discovery and management.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the routes to.</param>
|
||||
/// <returns>The <see cref="IEndpointRouteBuilder"/> for method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// This extension method registers the following endpoints:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>GET /v1/entities - List all registered entities (agents and workflows)</description></item>
|
||||
/// <item><description>GET /v1/entities/{entityId}/info - Get detailed information about a specific entity</description></item>
|
||||
/// </list>
|
||||
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
|
||||
/// from the registered <see cref="AgentCatalog"/> and <see cref="WorkflowCatalog"/> services.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/v1/entities")
|
||||
.WithTags("Entities");
|
||||
|
||||
// List all entities
|
||||
group.MapGet("", ListEntitiesAsync)
|
||||
.WithName("ListEntities")
|
||||
.WithSummary("List all registered entities (agents and workflows)")
|
||||
.Produces<DiscoveryResponse>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
// Get detailed entity information
|
||||
group.MapGet("{entityId}/info", GetEntityInfoAsync)
|
||||
.WithName("GetEntityInfo")
|
||||
.WithSummary("Get detailed information about a specific entity")
|
||||
.Produces<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEntitiesAsync(
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = new List<EntityInfo>();
|
||||
|
||||
// Discover agents from the agent catalog
|
||||
if (agentCatalog is not null)
|
||||
{
|
||||
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (agent.GetType().Name == "WorkflowHostAgent")
|
||||
{
|
||||
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
|
||||
// and workflows are handled below.
|
||||
continue;
|
||||
}
|
||||
|
||||
entities.Add(new EntityInfo(
|
||||
Id: agent.Name ?? agent.Id,
|
||||
Type: "agent",
|
||||
Name: agent.Name ?? agent.Id,
|
||||
Description: agent.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: null,
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discover workflows from the workflow catalog
|
||||
if (workflowCatalog is not null)
|
||||
{
|
||||
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Extract executor IDs from the workflow structure
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default input schema (string type)
|
||||
var defaultInputSchema = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "string"
|
||||
};
|
||||
|
||||
entities.Add(new EntityInfo(
|
||||
Id: workflow.Name ?? workflow.StartExecutorId,
|
||||
Type: "workflow",
|
||||
Name: workflow.Name ?? workflow.StartExecutorId,
|
||||
Description: workflow.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: [.. executorIds],
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory",
|
||||
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
|
||||
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
|
||||
InputTypeName = "string",
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Json(new DiscoveryResponse(entities), EntitiesJsonContext.Default.DiscoveryResponse);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Error listing entities");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEntityInfoAsync(
|
||||
string entityId,
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to find the entity among discovered agents
|
||||
if (agentCatalog is not null)
|
||||
{
|
||||
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (agent.GetType().Name == "WorkflowHostAgent")
|
||||
{
|
||||
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
|
||||
// and workflows are handled below.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(agent.Name, entityId, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(agent.Id, entityId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var entityInfo = new EntityInfo(
|
||||
Id: agent.Name ?? agent.Id,
|
||||
Type: "agent",
|
||||
Name: agent.Name ?? agent.Id,
|
||||
Description: agent.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: null,
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory"
|
||||
};
|
||||
|
||||
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the entity among discovered workflows
|
||||
if (workflowCatalog is not null)
|
||||
{
|
||||
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var workflowId = workflow.Name ?? workflow.StartExecutorId;
|
||||
if (string.Equals(workflowId, entityId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract executor IDs from the workflow structure
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default input schema (string type)
|
||||
var defaultInputSchema = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "string"
|
||||
};
|
||||
|
||||
var entityInfo = new EntityInfo(
|
||||
Id: workflowId,
|
||||
Type: "workflow",
|
||||
Name: workflow.Name ?? workflow.StartExecutorId,
|
||||
Description: workflow.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: [.. executorIds],
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory",
|
||||
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
|
||||
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
|
||||
InputTypeName = "Input",
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
};
|
||||
|
||||
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Results.NotFound(new { error = new { message = $"Entity '{entityId}' not found.", type = "invalid_request_error" } });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Error getting entity info");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Frontend paths - pointing to the Python package's frontend -->
|
||||
<FrontendRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\frontend'))</FrontendRoot>
|
||||
<FrontendBuildOutput>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\agent_framework_devui\ui'))</FrontendBuildOutput>
|
||||
<FrontendPackageJson>$(FrontendRoot)\package.json</FrontendPackageJson>
|
||||
<FrontendNodeModules>$(FrontendRoot)\node_modules</FrontendNodeModules>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Ensure npm packages are installed before building -->
|
||||
<Target Name="EnsureNodeModules" BeforeTargets="BeforeBuild" Condition="!Exists('$(FrontendNodeModules)')">
|
||||
<Exec Command="npm install" WorkingDirectory="$(FrontendRoot)" />
|
||||
</Target>
|
||||
|
||||
<!-- Collect frontend source files for incremental build tracking -->
|
||||
<ItemGroup>
|
||||
<FrontendSourceFiles Include="$(FrontendRoot)\src\**\*" />
|
||||
<FrontendSourceFiles Include="$(FrontendPackageJson);$(FrontendRoot)\vite.config.ts;$(FrontendRoot)\tsconfig.json;$(FrontendRoot)\index.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Define the required frontend assets -->
|
||||
<ItemGroup>
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\index.html" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.js" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.css" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\agentframework.svg" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Use a marker file for incremental build tracking -->
|
||||
<PropertyGroup>
|
||||
<FrontendBuildMarker>$(BaseIntermediateOutputPath)\frontend.build.marker</FrontendBuildMarker>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Build the frontend -->
|
||||
<Target Name="BuildFrontend" BeforeTargets="AssignTargetPaths" DependsOnTargets="EnsureNodeModules" Inputs="@(FrontendSourceFiles)" Outputs="$(FrontendBuildMarker)">
|
||||
<!-- Set VITE_API_BASE_URL to empty string for relative URLs -->
|
||||
<Exec Command="npm run build" WorkingDirectory="$(FrontendRoot)" EnvironmentVariables="VITE_API_BASE_URL=" />
|
||||
<!-- Create marker file to track successful build -->
|
||||
<Touch Files="$(FrontendBuildMarker)" AlwaysCreate="true" />
|
||||
</Target>
|
||||
|
||||
<!-- Statically include frontend assets as embedded resources for VS to show them -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(FrontendBuildOutput)\**\*" Condition="Exists('$(FrontendBuildOutput)')">
|
||||
<Link>resources\$([MSBuild]::MakeRelative('$(FrontendBuildOutput)', '%(Identity)'))</Link>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Verify required frontend assets are present -->
|
||||
<Target Name="ValidateFrontendAssets" BeforeTargets="CoreCompile" DependsOnTargets="BuildFrontend">
|
||||
<ItemGroup>
|
||||
<MissingAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="'@(MissingAsset)' != ''" Text="Required frontend assets are missing: @(MissingAsset, ', '). Frontend build may have failed." />
|
||||
</Target>
|
||||
|
||||
<!-- Verify assets are present before packing -->
|
||||
<Target Name="ValidateFrontendAssetsBeforePack" BeforeTargets="GenerateNuspec">
|
||||
<ItemGroup>
|
||||
<MissingPackageAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="'@(MissingPackageAsset)' != ''" Text="Cannot create NuGet package: Required frontend assets are missing: @(MissingPackageAsset, ', ')" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
|
||||
<OutputType>Library</OutputType>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- Suppress warnings for internal DevUI implementation -->
|
||||
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Import nuget packaging properties -->
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Import frontend web assets build targets -->
|
||||
<Import Project="Microsoft.Agents.AI.DevUI.Frontend.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Developer UI</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Microsoft.Agents.AI.DevUI": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:57966;http://localhost:57967"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Microsoft.Agents.AI.DevUI
|
||||
|
||||
This package provides a web interface for testing and debugging AI agents during development.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.DevUI
|
||||
dotnet add package Microsoft.Agents.AI.Hosting
|
||||
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Add DevUI services and map the endpoint in your ASP.NET Core application:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register your agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant.");
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Map DevUI endpoint to /devui
|
||||
app.MapDevUI();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
<script type="module" crossorigin src="./assets/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+5
-1
@@ -6,13 +6,17 @@
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AGUI.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<DefineConstants>$(DefineConstants);ASPNETCORE</DefineConstants>
|
||||
<IsPackable>false</IsPackable>
|
||||
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Hosting AG-UI ASP.NET Core</Title>
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user