Merge branch 'main' into feature-python-foundry-agents

This commit is contained in:
Dmytro Struk
2025-11-05 08:04:26 -08:00
214 changed files with 22261 additions and 1154 deletions
@@ -1052,7 +1052,7 @@ AgentThread thread = agent.GetNewThread();
**Add Agent Framework Packages:**
```xml
<PackageReference Include="Microsoft.Agents.AI.AzureAI" />
<PackageReference Include="Microsoft.Agents.AI.AzureAI.Persistent" />
<PackageReference Include="Azure.Identity" />
```
</configuration_changes>
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.10", "3.14"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
strategy:
fail-fast: true
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
# TODO(ekzhu): re-enable macos-latest when this is fixed: https://github.com/actions/runner-images/issues/11881
os: [ubuntu-latest, windows-latest]
env:
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: true
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
# todo: add macos-latest when problems are resolved
os: [ubuntu-latest, windows-latest]
env:
+9 -1
View File
@@ -203,4 +203,12 @@ agents.md
# AI
.claude/
WARP.md
WARP.md
# Frontend
**/frontend/node_modules/
**/frontend/.vite/
**/frontend/dist/
# Database files
*.db
+3 -2
View File
@@ -143,6 +143,7 @@
<Project Path="samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj" />
</Folder>
<Folder Name="/Samples/Catalog/">
<Project Path="samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
@@ -271,7 +272,7 @@
<Folder Name="/src/">
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
<Project Path="src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.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.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
@@ -297,7 +298,7 @@
<Folder Name="/Tests/UnitTests/">
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251028.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251028.1</PackageVersion>
<GitTag>1.0.0-preview.251028.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251104.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251104.1</PackageVersion>
<GitTag>1.0.0-preview.251104.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -20,7 +20,7 @@
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -20,14 +20,14 @@ builder.Services.AddProblemDetails();
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");
builder.AddAIAgent(
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
description: "An agent that speaks like a pirate.",
chatClientServiceKey: "chat-model")
.WithInMemoryThreadStore();
builder.AddAIAgent("knights-and-knaves", (sp, key) =>
var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
@@ -80,6 +80,8 @@ var literatureAgent = builder.AddAIAgent("literator",
builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
var app = builder.Build();
@@ -104,8 +106,8 @@ app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", age
app.MapOpenAIResponses();
app.MapOpenAIChatCompletions("pirate");
app.MapOpenAIChatCompletions("knights-and-knaves");
app.MapOpenAIChatCompletions(pirateAgentBuilder);
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
// Map the agents HTTP endpoints
app.MapAgentDiscovery("/agents");
@@ -28,7 +28,7 @@ AIAgent agent = new AzureOpenAIClient(
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -14,7 +14,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
</Project>
@@ -14,7 +14,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
</Project>
@@ -63,9 +63,7 @@ AIAgent agent = azureOpenAIClient
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(SearchAdapter, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -72,9 +72,7 @@ AIAgent agent = azureOpenAIClient
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(SearchAdapter, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -18,7 +18,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
</Project>
@@ -28,9 +28,7 @@ AIAgent agent = new AzureOpenAIClient(
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(MockSearchAsync, textSearchOptions)
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
@@ -33,9 +33,9 @@ AIAgent agent = new AzureOpenAIClient(
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null or JsonValueKind.Undefined
// If each thread should have its own Mem0 scope, you can create a new id per thread here:
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ThreadId = Guid.NewGuid().ToString() })
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() })
// In this case we are storing memories scoped by application and user instead so that memories are retained across threads.
? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
// For cases where we are restoring from serialized state:
: new Mem0Provider(mem0HttpClient, ctx.SerializedState, ctx.JsonSerializerOptions)
});
@@ -14,7 +14,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
</Project>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -15,7 +15,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -20,7 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -19,6 +19,7 @@ Please begin with the [Foundational](./_Foundational) samples in order. These th
| [Multi-Service Workflows](./_Foundational/05_MultiModelService) | Shows using multiple AI services in the same workflow |
| [Sub-Workflows](./_Foundational/06_SubWorkflows) | Demonstrates composing workflows hierarchically by embedding workflows as executors |
| [Mixed Workflow with Agents and Executors](./_Foundational/07_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling |
| [Writer-Critic Workflow](./_Foundational/08_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops |
Once completed, please proceed to other samples listed below.
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -10,7 +10,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>WriterCriticWorkflow</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
</Project>
@@ -0,0 +1,409 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace WriterCriticWorkflow;
/// <summary>
/// This sample demonstrates an iterative refinement workflow between Writer and Critic agents.
///
/// The workflow implements a content creation and review loop that:
/// 1. Writer creates initial content based on the user's request
/// 2. Critic reviews the content and provides feedback using structured output
/// 3. If approved: Summary executor presents the final content
/// 4. If rejected: Writer revises based on feedback (loops back)
/// 5. Continues until approval or max iterations (3) is reached
///
/// This pattern is useful when you need:
/// - Iterative content improvement through feedback loops
/// - Quality gates with reviewer approval
/// - Maximum iteration limits to prevent infinite loops
/// - Conditional workflow routing based on agent decisions
/// - Structured output for reliable decision-making
///
/// Key Learning: Workflows can implement loops with conditional edges, shared state,
/// and structured output for robust agent decision-making.
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - Previous foundational samples should be completed first.
/// - An Azure OpenAI chat completion deployment must be configured.
/// </remarks>
public static class Program
{
public const int MaxIterations = 3;
private static async Task Main()
{
Console.WriteLine("\n=== Writer-Critic Iteration Workflow ===\n");
Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n");
// Set up the Azure OpenAI client
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
// Create executors for content creation and review
WriterExecutor writer = new(chatClient);
CriticExecutor critic = new(chatClient);
SummaryExecutor summary = new(chatClient);
// Build the workflow with conditional routing based on critic's decision
WorkflowBuilder workflowBuilder = new WorkflowBuilder(writer)
.AddEdge(writer, critic)
.AddSwitch(critic, sw => sw
.AddCase<CriticDecision>(cd => cd?.Approved == true, summary)
.AddCase<CriticDecision>(cd => cd?.Approved == false, writer))
.WithOutputFrom(summary);
// Execute the workflow with a sample task
// The workflow loops back to Writer if content is rejected,
// or proceeds to Summary if approved. State tracking ensures we don't loop forever.
Console.WriteLine(new string('=', 80));
Console.WriteLine("TASK: Write a short blog post about AI ethics (200 words)");
Console.WriteLine(new string('=', 80) + "\n");
const string InitialTask = "Write a 200-word blog post about AI ethics. Make it thoughtful and engaging.";
Workflow workflow = workflowBuilder.Build();
await ExecuteWorkflowAsync(workflow, InitialTask);
Console.WriteLine("\n✅ Sample Complete: Writer-Critic iteration demonstrates conditional workflow loops\n");
Console.WriteLine("Key Concepts Demonstrated:");
Console.WriteLine(" ✓ Iterative refinement loop with conditional routing");
Console.WriteLine(" ✓ Shared workflow state for iteration tracking");
Console.WriteLine($" ✓ Max iteration cap ({MaxIterations}) for safety");
Console.WriteLine(" ✓ Multiple message handlers in a single executor");
Console.WriteLine(" ✓ Streaming support with structured output\n");
}
private static async Task ExecuteWorkflowAsync(Workflow workflow, string input)
{
// Execute in streaming mode to see real-time progress
await using StreamingRun run = await InProcessExecution.StreamAsync<string>(workflow, input);
// Watch the workflow events
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case AgentRunUpdateEvent agentUpdate:
// Stream agent output in real-time
if (!string.IsNullOrEmpty(agentUpdate.Update.Text))
{
Console.Write(agentUpdate.Update.Text);
}
break;
case WorkflowOutputEvent output:
Console.WriteLine("\n\n" + new string('=', 80));
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✅ FINAL APPROVED CONTENT");
Console.ResetColor();
Console.WriteLine(new string('=', 80));
Console.WriteLine();
Console.WriteLine(output.Data);
Console.WriteLine();
Console.WriteLine(new string('=', 80));
break;
}
}
}
}
// ====================================
// Shared State for Iteration Tracking
// ====================================
/// <summary>
/// Tracks the current iteration and conversation history across workflow executions.
/// </summary>
internal sealed class FlowState
{
public int Iteration { get; set; } = 1;
public List<ChatMessage> History { get; } = [];
}
/// <summary>
/// Constants for accessing the shared flow state in workflow context.
/// </summary>
internal static class FlowStateShared
{
public const string Scope = "FlowStateScope";
public const string Key = "singleton";
}
/// <summary>
/// Helper methods for reading and writing shared flow state.
/// </summary>
internal static class FlowStateHelpers
{
public static async Task<FlowState> ReadFlowStateAsync(IWorkflowContext context)
{
FlowState? state = await context.ReadStateAsync<FlowState>(FlowStateShared.Key, scopeName: FlowStateShared.Scope);
return state ?? new FlowState();
}
public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState state)
=> context.QueueStateUpdateAsync(FlowStateShared.Key, state, scopeName: FlowStateShared.Scope);
}
// ====================================
// Data Transfer Objects
// ====================================
/// <summary>
/// Structured output schema for the Critic's decision.
/// Uses JsonPropertyName and Description attributes for OpenAI's JSON schema.
/// </summary>
[Description("Critic's review decision including approval status and feedback")]
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via JSON deserialization")]
internal sealed class CriticDecision
{
[JsonPropertyName("approved")]
[Description("Whether the content is approved (true) or needs revision (false)")]
public bool Approved { get; set; }
[JsonPropertyName("feedback")]
[Description("Specific feedback for improvements if not approved, empty if approved")]
public string Feedback { get; set; } = "";
// Non-JSON properties for workflow use
[JsonIgnore]
public string Content { get; set; } = "";
[JsonIgnore]
public int Iteration { get; set; }
}
// ====================================
// Custom Executors
// ====================================
/// <summary>
/// Executor that creates or revises content based on user requests or critic feedback.
/// This executor demonstrates multiple message handlers for different input types.
/// </summary>
internal sealed class WriterExecutor : Executor
{
private readonly AIAgent _agent;
public WriterExecutor(IChatClient chatClient) : base("Writer")
{
this._agent = new ChatClientAgent(
chatClient,
name: "Writer",
instructions: """
You are a skilled writer. Create clear, engaging content.
If you receive feedback, carefully revise the content to address all concerns.
Maintain the same topic and length requirements.
"""
);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string, ChatMessage>(this.HandleInitialRequestAsync)
.AddHandler<CriticDecision, ChatMessage>(this.HandleRevisionRequestAsync);
/// <summary>
/// Handles the initial writing request from the user.
/// </summary>
private async ValueTask<ChatMessage> HandleInitialRequestAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
}
/// <summary>
/// Handles revision requests from the critic with feedback.
/// </summary>
private async ValueTask<ChatMessage> HandleRevisionRequestAsync(
CriticDecision decision,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
string prompt = "Revise the following content based on this feedback:\n\n" +
$"Feedback: {decision.Feedback}\n\n" +
$"Original Content:\n{decision.Content}";
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, prompt), context, cancellationToken);
}
/// <summary>
/// Core implementation for generating content (initial or revised).
/// </summary>
private async Task<ChatMessage> HandleAsyncCoreAsync(
ChatMessage message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
Console.WriteLine($"\n=== Writer (Iteration {state.Iteration}) ===\n");
StringBuilder sb = new();
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
sb.Append(update.Text);
Console.Write(update.Text);
}
}
Console.WriteLine("\n");
string text = sb.ToString();
state.History.Add(new ChatMessage(ChatRole.Assistant, text));
await FlowStateHelpers.SaveFlowStateAsync(context, state);
return new ChatMessage(ChatRole.User, text);
}
}
/// <summary>
/// Executor that reviews content and decides whether to approve or request revisions.
/// Uses structured output with streaming for reliable decision-making.
/// </summary>
internal sealed class CriticExecutor : Executor<ChatMessage, CriticDecision>
{
private readonly AIAgent _agent;
public CriticExecutor(IChatClient chatClient) : base("Critic")
{
this._agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
Name = "Critic",
Instructions = """
You are a constructive critic. Review the content and provide specific feedback.
Always try to provide actionable suggestions for improvement and strive to identify improvement points.
Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points.
Provide your decision as structured output with:
- approved: true if content is good, false if revisions needed
- feedback: specific improvements needed (empty if approved)
Be concise but specific in your feedback.
""",
ChatOptions = new()
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<CriticDecision>()
}
});
}
public override async ValueTask<CriticDecision> HandleAsync(
ChatMessage message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
Console.WriteLine($"=== Critic (Iteration {state.Iteration}) ===\n");
// Use RunStreamingAsync to get streaming updates, then deserialize at the end
IAsyncEnumerable<AgentRunResponseUpdate> updates = this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken);
// Stream the output in real-time (for any rationale/explanation)
await foreach (AgentRunResponseUpdate update in updates)
{
if (!string.IsNullOrEmpty(update.Text))
{
Console.Write(update.Text);
}
}
Console.WriteLine("\n");
// Convert the stream to a response and deserialize the structured output
AgentRunResponse response = await updates.ToAgentRunResponseAsync(cancellationToken);
CriticDecision decision = response.Deserialize<CriticDecision>(JsonSerializerOptions.Web);
Console.WriteLine($"Decision: {(decision.Approved ? " APPROVED" : " NEEDS REVISION")}");
if (!string.IsNullOrEmpty(decision.Feedback))
{
Console.WriteLine($"Feedback: {decision.Feedback}");
}
Console.WriteLine();
// Safety: approve if max iterations reached
if (!decision.Approved && state.Iteration >= Program.MaxIterations)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"⚠️ Max iterations ({Program.MaxIterations}) reached - auto-approving");
Console.ResetColor();
decision.Approved = true;
decision.Feedback = "";
}
// Increment iteration ONLY if rejecting (will loop back to Writer)
if (!decision.Approved)
{
state.Iteration++;
}
// Store the decision in history
state.History.Add(new ChatMessage(ChatRole.Assistant,
$"[Decision: {(decision.Approved ? "Approved" : "Needs Revision")}] {decision.Feedback}"));
await FlowStateHelpers.SaveFlowStateAsync(context, state);
// Populate workflow-specific fields
decision.Content = message.Text ?? "";
decision.Iteration = state.Iteration;
return decision;
}
}
/// <summary>
/// Executor that presents the final approved content to the user.
/// </summary>
internal sealed class SummaryExecutor : Executor<CriticDecision, ChatMessage>
{
private readonly AIAgent _agent;
public SummaryExecutor(IChatClient chatClient) : base("Summary")
{
this._agent = new ChatClientAgent(
chatClient,
name: "Summary",
instructions: """
You present the final approved content to the user.
Simply output the polished content - no additional commentary needed.
"""
);
}
public override async ValueTask<ChatMessage> HandleAsync(
CriticDecision message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine("=== Summary ===\n");
string prompt = $"Present this approved content:\n\n{message.Content}";
StringBuilder sb = new();
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
sb.Append(update.Text);
}
}
ChatMessage result = new(ChatRole.Assistant, sb.ToString());
await context.YieldOutputAsync(result, cancellationToken);
return result;
}
}
@@ -1,70 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Buffers;
using System.ClientModel.Primitives;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using OpenAI.Chat;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
internal sealed class AIAgentChatCompletionsProcessor
internal static class AIAgentChatCompletionsProcessor
{
private readonly AIAgent _agent;
public AIAgentChatCompletionsProcessor(AIAgent agent)
public static async Task<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken)
{
this._agent = agent;
}
ArgumentNullException.ThrowIfNull(agent);
public async Task<IResult> CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken)
{
AgentThread? agentThread = null; // not supported to resolve from conversationId
var chatMessages = request.Messages.Select(i => i.ToChatMessage());
var chatClientAgentRunOptions = request.BuildOptions();
var inputItems = chatCompletionOptions.GetMessages();
var chatMessages = inputItems.AsChatMessages();
if (chatCompletionOptions.GetStream())
if (request.Stream == true)
{
return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages);
return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions);
}
var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false);
return new OpenAIChatCompletionResult(agentResponse);
var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Ok(response.ToChatCompletion(request));
}
private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
{
// note: OpenAI SDK types provide their own serialization implementation
// so we cant simply return IResult wrap for the typed-object.
// instead writing to the response body can be done.
var cancellationToken = httpContext.RequestAborted;
var response = httpContext.Response;
var chatResponse = agentRunResponse.AsChatResponse();
var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion();
var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel<ChatCompletion>;
Debug.Assert(openAIChatCompletionJsonModel is not null);
var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false });
openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}
private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable<ChatMessage> chatMessages) : IResult
private sealed class StreamingResponse(
AIAgent agent,
CreateChatCompletion request,
IEnumerable<ChatMessage> chatMessages,
ChatClientAgentRunOptions? options) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
@@ -79,26 +53,99 @@ internal sealed class AIAgentChatCompletionsProcessor
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
return SseFormatter.WriteAsync(
source: this.GetStreamingResponsesAsync(cancellationToken),
source: this.GetStreamingChunksAsync(cancellationToken),
destination: response.Body,
itemFormatter: (sseItem, bufferWriter) =>
{
var sseDataJsonModel = (IJsonModel<StreamingChatCompletionUpdate>)sseItem.Data;
var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json);
bufferWriter.Write(json);
using var writer = new Utf8JsonWriter(bufferWriter);
JsonSerializer.Serialize(writer, sseItem.Data, ChatCompletionsJsonContext.Default.ChatCompletionChunk);
writer.Flush();
},
cancellationToken);
}
private async IAsyncEnumerable<SseItem<StreamingChatCompletionUpdate>> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
private async IAsyncEnumerable<SseItem<ChatCompletionChunk>> GetStreamingChunksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
AgentThread? agentThread = null;
// 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 agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken);
var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync();
await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false))
await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
{
yield return new SseItem<StreamingChatCompletionUpdate>(streamingChatCompletionUpdate);
var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
? chatResponseUpdate.FinishReason.ToString()
: "stop";
var choiceChunks = new List<ChatCompletionChoiceChunk>();
CompletionUsage? usageDetails = null;
createdAt ??= agentRunResponseUpdate.CreatedAt;
foreach (var content in agentRunResponseUpdate.Contents)
{
// usage content is handled separately
if (content is UsageContent usageContent && usageContent.Details != null)
{
usageDetails = usageContent.Details.ToCompletionUsage();
continue;
}
ChatCompletionDelta? delta = content switch
{
TextContent textContent => new() { Content = textContent.Text },
// image
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() { Content = imageContent.Base64Data.ToString() },
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() { Content = urlContent.Uri.ToString() },
// audio
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() { Content = audioContent.Base64Data.ToString() },
// file
DataContent fileContent => new() { Content = fileContent.Base64Data.ToString() },
HostedFileContent fileContent => new() { Content = fileContent.FileId },
// function call
FunctionCallContent functionCallContent => new()
{
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
},
// function result. ChatCompletions dont provide the results of function result per API reference
FunctionResultContent functionResultContent => null,
// ignore
_ => null
};
if (delta is null)
{
// unsupported but expected content type.
continue;
}
delta.Role = agentRunResponseUpdate.Role?.Value ?? "user";
var choiceChunk = new ChatCompletionChoiceChunk
{
Index = 0,
Delta = delta,
FinishReason = finishReason
};
choiceChunks.Add(choiceChunk);
}
var chunk = new ChatCompletionChunk
{
Id = chunkId,
Created = (createdAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
Model = request.Model,
Choices = choiceChunks,
Usage = usageDetails
};
yield return new(chunk);
}
}
}
@@ -0,0 +1,209 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
/// <summary>
/// Extension methods for converting agent responses to ChatCompletion models.
/// </summary>
internal static class AgentRunResponseExtensions
{
public static ChatCompletion ToChatCompletion(this AgentRunResponse agentRunResponse, CreateChatCompletion request)
{
IList<ChatCompletionChoice> choices = agentRunResponse.ToChoices();
return new ChatCompletion
{
Id = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13),
Choices = choices,
Created = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
Model = request.Model,
Usage = agentRunResponse.Usage.ToCompletionUsage(),
ServiceTier = request.ServiceTier ?? "default"
};
}
public static List<ChatCompletionChoice> ToChoices(this AgentRunResponse agentRunResponse)
{
var chatCompletionChoices = new List<ChatCompletionChoice>();
var index = 0;
var finishReason = (agentRunResponse.RawRepresentation is ChatResponse { FinishReason: not null } chatResponse)
? chatResponse.FinishReason.ToString()
: "stop"; // "stop" is a natural stop point; returning this by-default
foreach (var message in agentRunResponse.Messages)
{
foreach (var content in message.Contents)
{
ChoiceMessage? choiceMessage = content switch
{
// text
TextContent textContent => new()
{
Content = textContent.Text
},
// image, see how MessageContentPartConverter packs the content types
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new()
{
Content = imageContent.Base64Data.ToString()
},
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new()
{
Content = urlContent.Uri.ToString()
},
// audio
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new()
{
Audio = new()
{
Data = audioContent.Base64Data.ToString(),
Id = audioContent.Name,
//Transcript = ,
//ExpiresAt = ,
},
},
// file (neither audio nor image)
DataContent fileContent => new()
{
Content = fileContent.Base64Data.ToString()
},
HostedFileContent fileContent => new()
{
Content = fileContent.FileId
},
// function call
FunctionCallContent functionCallContent => new()
{
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
},
// function result. ChatCompletions dont provide the results of function result per API reference
FunctionResultContent functionResultContent => null,
// ignore
_ => null
};
if (choiceMessage is null)
{
// not supported, but expected content type.
continue;
}
choiceMessage.Role = message.Role.Value;
choiceMessage.Annotations = content.Annotations?.ToChoiceMessageAnnotations();
var choice = new ChatCompletionChoice
{
Index = index++,
Message = choiceMessage,
FinishReason = finishReason
};
chatCompletionChoices.Add(choice);
}
}
return chatCompletionChoices;
}
/// <summary>
/// Converts UsageDetails to CompletionUsage.
/// </summary>
/// <param name="usage">The usage details to convert.</param>
/// <returns>A CompletionUsage object with zeros if usage is null.</returns>
public static CompletionUsage ToCompletionUsage(this UsageDetails? usage)
{
if (usage == null)
{
return CompletionUsage.Zero;
}
var cachedTokens = usage.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cachedInputToken) ?? false
? (int)cachedInputToken
: 0;
var reasoningTokens =
usage.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoningToken) ?? false
? (int)reasoningToken
: 0;
return new CompletionUsage
{
PromptTokens = (int)(usage.InputTokenCount ?? 0),
PromptTokensDetails = new() { CachedTokens = cachedTokens },
CompletionTokens = (int)(usage.OutputTokenCount ?? 0),
CompletionTokensDetails = new() { ReasoningTokens = reasoningTokens },
TotalTokens = (int)(usage.TotalTokenCount ?? 0)
};
}
public static IList<ChoiceMessageAnnotation> ToChoiceMessageAnnotations(this IList<AIAnnotation> annotations)
{
var result = new List<ChoiceMessageAnnotation>();
foreach (var annotation in annotations.OfType<CitationAnnotation>())
{
if (annotation is null)
{
continue;
}
// may point to mulitple regions in the AIContent.
// we need to unroll another loop for regions then -> chatCompletions only point to single region per annotation
var regions = annotation.AnnotatedRegions?.OfType<TextSpanAnnotatedRegion>().Where(x => x.StartIndex is not null && x.EndIndex is not null);
if (regions is not null)
{
foreach (var region in regions)
{
result.Add(new()
{
AnnotationUrlCitation = new AnnotationUrlCitation
{
Url = annotation.Url?.ToString(),
Title = annotation.Title,
StartIndex = region.StartIndex,
EndIndex = region.EndIndex
}
});
}
}
else
{
result.Add(new()
{
AnnotationUrlCitation = new AnnotationUrlCitation
{
Url = annotation.Url?.ToString(),
Title = annotation.Title
}
});
}
}
return result;
}
public static ChoiceMessageToolCall ToChoiceMessageToolCall(this FunctionCallContent functionCall)
{
return new()
{
Id = functionCall.CallId,
Function = new()
{
Name = functionCall.Name,
Arguments = JsonSerializer.Serialize(functionCall.Arguments, ChatCompletionsJsonContext.Default.DictionaryStringObject)
}
};
}
}
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
AllowOutOfOrderMetadataProperties = true,
WriteIndented = false)]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(CreateChatCompletion))]
[JsonSerializable(typeof(StopSequences))]
[JsonSerializable(typeof(ChatCompletion))]
[JsonSerializable(typeof(ChatCompletionRequestMessage))]
[JsonSerializable(typeof(IList<ChatCompletionRequestMessage>))]
[JsonSerializable(typeof(MessageContent))]
[JsonSerializable(typeof(MessageContentPart))]
[JsonSerializable(typeof(IReadOnlyList<MessageContentPart>))]
[JsonSerializable(typeof(TextContentPart))]
[JsonSerializable(typeof(ImageContentPart))]
[JsonSerializable(typeof(AudioContentPart))]
[JsonSerializable(typeof(FileContentPart))]
[JsonSerializable(typeof(ChatCompletionChoice))]
[JsonSerializable(typeof(IList<ChatCompletionChoice>))]
[JsonSerializable(typeof(ChoiceMessage))]
[JsonSerializable(typeof(ChoiceMessageAnnotation))]
[JsonSerializable(typeof(ChoiceMessageAudio))]
[JsonSerializable(typeof(ChoiceMessageFunctionCall))]
[JsonSerializable(typeof(ChoiceMessageToolCall))]
[JsonSerializable(typeof(AnnotationUrlCitation))]
[JsonSerializable(typeof(ChatCompletionChoiceChunk))]
[JsonSerializable(typeof(IList<ChatCompletionChoiceChunk>))]
[JsonSerializable(typeof(ChatCompletionChunk))]
[JsonSerializable(typeof(ChatCompletionDelta))]
[JsonSerializable(typeof(ToolChoice))]
[JsonSerializable(typeof(AllowedToolsChoice))]
[JsonSerializable(typeof(AllowedToolsConfiguration))]
[JsonSerializable(typeof(ToolDefinition))]
[JsonSerializable(typeof(IList<ToolDefinition>))]
[JsonSerializable(typeof(FunctionReference))]
[JsonSerializable(typeof(FunctionToolChoice))]
[JsonSerializable(typeof(CustomToolChoice))]
[JsonSerializable(typeof(CustomToolObject))]
[JsonSerializable(typeof(ResponseFormat))]
[JsonSerializable(typeof(TextResponseFormat))]
[JsonSerializable(typeof(JsonSchemaResponseFormat))]
[JsonSerializable(typeof(JsonSchemaConfiguration))]
[JsonSerializable(typeof(JsonObjectResponseFormat))]
[JsonSerializable(typeof(Tool))]
[JsonSerializable(typeof(IList<Tool>))]
[JsonSerializable(typeof(FunctionTool))]
[JsonSerializable(typeof(FunctionDefinition))]
[JsonSerializable(typeof(CustomTool))]
[JsonSerializable(typeof(CustomToolProperties))]
[JsonSerializable(typeof(CustomToolFormat))]
[ExcludeFromCodeCoverage]
internal sealed partial class ChatCompletionsJsonContext : JsonSerializerContext;
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
/// <summary>
/// Extension methods for JSON serialization.
/// </summary>
internal static class ChatCompletionsJsonSerializerOptions
{
/// <summary>
/// Gets the default JSON serializer options.
/// </summary>
public static JsonSerializerOptions Default { get; } = Create();
private static JsonSerializerOptions Create()
{
JsonSerializerOptions options = new(ChatCompletionsJsonContext.Default.Options);
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
}
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
internal static class ChatClientAgentRunOptionsConverter
{
private static readonly JsonElement s_emptyJson = JsonDocument.Parse("{}").RootElement;
public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request)
{
ChatOptions chatOptions = new()
{
Temperature = request.Temperature,
MaxOutputTokens = request.MaxCompletionTokens,
FrequencyPenalty = request.FrequencyPenalty,
PresencePenalty = request.PresencePenalty,
Seed = request.Seed,
TopP = request.TopP,
StopSequences = request.Stop?.SequenceList ?? [],
ResponseFormat = request.ResponseFormat?.ToChatResponseFormat()
};
if (request.ToolChoice is not null)
{
chatOptions.ToolMode = request.ToolChoice.ToChatToolMode();
}
if (request.Tools?.Count > 0)
{
chatOptions.Tools = request.Tools.Select(x => x.ToAITool()).ToList();
}
return new()
{
ChatOptions = chatOptions
};
}
private static ChatResponseFormat ToChatResponseFormat(this ResponseFormat responseFormat)
{
if (responseFormat.IsText)
{
return ChatResponseFormat.Text;
}
if (responseFormat.IsJsonObject)
{
return ChatResponseFormat.Json;
}
if (responseFormat.IsJsonSchema)
{
var schema = responseFormat.JsonSchema.JsonSchema;
return ChatResponseFormat.ForJsonSchema(schema.Schema, schema.Name, schema.Description);
}
throw new ArgumentOutOfRangeException(nameof(responseFormat));
}
private static AITool ToAITool(this Tool tool)
{
if (tool is FunctionTool functionTool)
{
var function = functionTool.Function;
return AIFunctionFactory.CreateDeclaration(function.Name, function.Description, function.Parameters ?? s_emptyJson);
}
if (tool is CustomTool customTool)
{
var custom = customTool.Custom;
return new CustomAITool(custom.Name, custom.Description, custom.Format?.AdditionalProperties);
}
throw new ArgumentOutOfRangeException(nameof(tool));
}
private static ChatToolMode? ToChatToolMode(this ToolChoice toolChoice)
{
if (toolChoice.IsMode)
{
return toolChoice.Mode switch
{
"auto" => ChatToolMode.Auto,
"none" => ChatToolMode.None,
"required" => ChatToolMode.RequireAny,
_ => null
};
}
if (toolChoice.IsAllowedTools)
{
var mode = toolChoice.AllowedTools.AllowedTools.Mode;
return mode switch
{
"auto" => ChatToolMode.Auto,
"required" => ChatToolMode.RequireAny,
_ => null
};
}
if (toolChoice.IsFunctionTool)
{
var function = toolChoice.FunctionTool.Function;
return ChatToolMode.RequireSpecific(function.Name);
}
if (toolChoice.IsCustomTool)
{
var custom = toolChoice.CustomTool.Custom;
return ChatToolMode.RequireSpecific(custom.Name);
}
throw new ArgumentOutOfRangeException(nameof(toolChoice));
}
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
internal static class MessageContentPartConverter
{
public static AIContent? ToAIContent(MessageContentPart part)
{
return part switch
{
// text
TextContentPart textPart => new TextContent(textPart.Text),
// image
ImageContentPart imagePart when !string.IsNullOrEmpty(imagePart.UrlOrData) =>
imagePart.UrlOrData.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
? new DataContent(imagePart.UrlOrData, "image/*")
: new UriContent(imagePart.Url, ImageUriToMediaType(imagePart.Url)),
// 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/*"
}),
// file
FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileId)
=> new HostedFileContent(filePart.File.FileId),
FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileData)
=> new DataContent(filePart.File.FileData, "application/octet-stream") { Name = filePart.File.Filename },
_ => null
};
}
private static string ImageUriToMediaType(Uri uri)
{
string absoluteUri = uri.AbsoluteUri;
return
absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" :
absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" :
absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" :
absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" :
absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" :
absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" :
"image/*";
}
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a chat completion response returned by the model, based on the provided input.
/// </summary>
internal sealed record ChatCompletion
{
/// <summary>
/// A unique identifier for the chat completion.
/// </summary>
[JsonPropertyName("id")]
[JsonRequired]
public required string Id { get; init; }
/// <summary>
/// The object type, which is always "chat.completion".
/// </summary>
[JsonPropertyName("object")]
public string Object { get; init; } = "chat.completion";
/// <summary>
/// The Unix timestamp (in seconds) of when the chat completion was created.
/// </summary>
[JsonPropertyName("created")]
[JsonRequired]
public required long Created { get; init; }
/// <summary>
/// The model used for the chat completion.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; init; }
/// <summary>
/// A list of chat completion choices. Can be more than one if n is greater than 1.
/// </summary>
[JsonPropertyName("choices")]
[JsonRequired]
public required IList<ChatCompletionChoice> Choices { get; init; }
/// <summary>
/// Usage statistics for the completion request.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CompletionUsage? Usage { get; init; }
/// <summary>
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; init; }
/// <summary>
/// This fingerprint represents the backend configuration that the model runs with.
/// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism.
/// </summary>
[JsonPropertyName("system_fingerprint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SystemFingerprint { get; init; }
}
@@ -0,0 +1,216 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a choice in a chat completion response.
/// </summary>
internal sealed record ChatCompletionChoice
{
/// <summary>
/// The index of the choice in the list of choices.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// The reason the model stopped generating tokens.
/// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached,
/// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool,
/// or function_call (deprecated) if the model called a function.
/// </summary>
[JsonPropertyName("finish_reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FinishReason { get; init; }
/// <summary>
/// A chat completion message generated by the model.
/// </summary>
[JsonPropertyName("message")]
public required ChoiceMessage Message { get; init; }
}
/// <summary>
/// A chat completion message generated by the model.
/// </summary>
internal sealed record ChoiceMessage
{
/// <summary>
/// The role of the author of this message.
/// </summary>
[JsonPropertyName("role")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Role { get; set; }
/// <summary>
/// A list of annotations for this message. Currently used for web search citations.
/// </summary>
[JsonPropertyName("annotations")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageAnnotation>? Annotations { get; set; }
/// <summary>
/// The contents of the message.
/// </summary>
[JsonPropertyName("content")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Content { get; set; }
/// <summary>
/// The refusal message generated by the model.
/// </summary>
[JsonPropertyName("refusal")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Refusal { get; set; }
/// <summary>
/// If the audio output modality is requested, this object contains data about the audio response from the model.
/// </summary>
[JsonPropertyName("audio")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageAudio? Audio { get; set; }
/// <summary>
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
/// <summary>
/// The tool calls generated by the model, such as function calls.
/// </summary>
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
}
/// <summary>
/// Audio output data in a chat completion message.
/// </summary>
internal sealed record ChoiceMessageAudio
{
/// <summary>
/// Base64 encoded audio bytes generated by the model, in the format specified in the request.
/// </summary>
[JsonPropertyName("data")]
public string? Data { get; init; }
/// <summary>
/// The Unix timestamp (in seconds) for when this audio response will no longer be accessible on the server for use in multi-turn conversations.
/// </summary>
[JsonPropertyName("expires_at")]
public int ExpiresAt { get; init; }
/// <summary>
/// Unique identifier for this audio response.
/// </summary>
[JsonPropertyName("id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
/// <summary>
/// Transcript of the audio generated by the model.
/// </summary>
[JsonPropertyName("transcript")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Transcript { get; init; }
}
/// <summary>
/// Deprecated. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
internal sealed record ChoiceMessageFunctionCall
{
/// <summary>
/// The name of the function to call.
/// </summary>
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; init; }
/// <summary>
/// The arguments to call the function with, as generated by the model in JSON format.
/// Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema.
/// Validate the arguments in your code before calling your function.
/// </summary>
[JsonPropertyName("arguments")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Arguments { get; init; }
}
/// <summary>
/// Represents a tool call generated by the model.
/// </summary>
internal sealed record ChoiceMessageToolCall
{
/// <summary>
/// The ID of the tool call.
/// </summary>
[JsonPropertyName("id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
/// <summary>
/// The type of the tool.
/// </summary>
public string Type => "function";
/// <summary>
/// The function that the model called.
/// </summary>
[JsonPropertyName("function")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? Function { get; set; }
}
/// <summary>
/// An annotation for a message, used for web search citations.
/// </summary>
internal sealed record ChoiceMessageAnnotation
{
/// <summary>
/// The type of annotation. Always 'url_citation' for web search results.
/// </summary>
[JsonPropertyName("type")]
public string Type => "url_citation";
/// <summary>
/// The URL citation details.
/// </summary>
[JsonPropertyName("url_citation")]
public required AnnotationUrlCitation AnnotationUrlCitation { get; init; }
}
/// <summary>
/// A citation to a URL for a web search result.
/// </summary>
internal sealed record AnnotationUrlCitation
{
/// <summary>
/// The character index in the message content where the citation ends.
/// </summary>
[JsonPropertyName("end_index")]
public int? EndIndex { get; init; }
/// <summary>
/// The character index in the message content where the citation starts.
/// </summary>
[JsonPropertyName("start_index")]
public int? StartIndex { get; init; }
/// <summary>
/// The title of the cited resource.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// The URL of the cited resource.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
}
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a chunk of chat completion response returned by the model, based on the provided input.
/// </summary>
internal sealed record ChatCompletionChunk
{
/// <summary>
/// A unique identifier for the chat completion. Each chunk has the same ID.
/// </summary>
[JsonPropertyName("id")]
[JsonRequired]
public required string Id { get; init; }
/// <summary>
/// A list of chat completion choices. Can be more than one if n is greater than 1.
/// </summary>
[JsonPropertyName("choices")]
[JsonRequired]
public required IList<ChatCompletionChoiceChunk> Choices { get; init; }
/// <summary>
/// The object type, which is always "chat.completion.chunk".
/// </summary>
[JsonPropertyName("object")]
public string Object => "chat.completion.chunk";
/// <summary>
/// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
/// </summary>
[JsonPropertyName("created")]
[JsonRequired]
public required long Created { get; init; }
/// <summary>
/// The model to generate the completion.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; init; }
/// <summary>
/// Usage statistics for the completion request.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CompletionUsage? Usage { get; init; }
/// <summary>
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; init; }
/// <summary>
/// This fingerprint represents the backend configuration that the model runs with.
/// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism.
/// </summary>
[JsonPropertyName("system_fingerprint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SystemFingerprint { get; init; }
}
internal sealed record ChatCompletionChoiceChunk
{
/// <summary>
/// The index of the choice in the list of choices.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// The reason the model stopped generating tokens.
/// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached,
/// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool, or function_call (deprecated) if the model called a function.
/// </summary>
[JsonPropertyName("finish_reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FinishReason { get; init; }
[JsonPropertyName("delta")]
public required ChatCompletionDelta Delta { get; init; }
}
internal sealed record ChatCompletionDelta
{
/// <summary>
/// The contents of the chunk message.
/// </summary>
[JsonPropertyName("content")]
public string? Content { get; init; }
/// <summary>
/// The refusal message generated by the model.
/// </summary>
[JsonPropertyName("refusal")]
public string? Refusal { get; init; }
/// <summary>
/// The role of the author of this message.
/// </summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
/// <summary>
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
}
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a message in a chat completion request.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "role", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(DeveloperMessage), "developer")]
[JsonDerivedType(typeof(SystemMessage), "system")]
[JsonDerivedType(typeof(UserMessage), "user")]
[JsonDerivedType(typeof(AssistantMessage), "assistant")]
[JsonDerivedType(typeof(ToolMessage), "tool")]
[JsonDerivedType(typeof(FunctionMessage), "function")]
internal abstract record ChatCompletionRequestMessage
{
/// <summary>
/// The role of the content.
/// </summary>
[JsonIgnore]
public abstract string Role { get; }
/// <summary>
/// The contents of the message.
/// </summary>
[JsonPropertyName("content")]
public required MessageContent Content { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is neither text nor AI contents.</exception>
public virtual ChatMessage ToChatMessage()
{
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
}
else if (this.Content.IsContents)
{
var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList();
return new ChatMessage(ChatRole.User, aiContents!);
}
throw new InvalidOperationException("MessageContent has no value");
}
}
/// <summary>
/// A developer message in a chat completion request.
/// Developer messages are used to provide instructions to the model at the system level.
/// </summary>
internal sealed record DeveloperMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "developer";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A system message in a chat completion request.
/// System messages provide high-level instructions for the conversation.
/// </summary>
internal sealed record SystemMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "system";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A user message in a chat completion request.
/// User messages represent input from the end user.
/// </summary>
internal sealed record UserMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "user";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// An assistant message in a chat completion request.
/// Assistant messages represent previous responses from the model, used in multi-turn conversations.
/// </summary>
internal sealed record AssistantMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "assistant";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A tool message in a chat completion request.
/// Tool messages contain the result of a tool call made by the assistant.
/// </summary>
internal sealed record ToolMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "tool";
/// <summary>
/// Tool call that this message is responding to.
/// </summary>
[JsonPropertyName("tool_call_id")]
public required string ToolCallId { get; set; }
}
/// <summary>
/// Deprecated. A function message in a chat completion request.
/// Function messages have been replaced by tool messages.
/// </summary>
internal sealed record FunctionMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "function";
/// <summary>
/// The name of the function to call.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is not text.</exception>
public override ChatMessage ToChatMessage()
{
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
}
throw new InvalidOperationException("FunctionMessage Content must be text");
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents usage statistics for a chat completion request.
/// </summary>
internal sealed record CompletionUsage
{
public static CompletionUsage Zero { get; } = new()
{
CompletionTokens = 0,
PromptTokens = 0,
TotalTokens = 0,
CompletionTokensDetails = new()
{
AcceptedPredictionTokens = 0,
AudioTokens = 0,
ReasoningTokens = 0,
RejectedPredictionTokens = 0
},
PromptTokensDetails = new()
{
AudioTokens = 0,
CachedTokens = 0
},
};
/// <summary>
/// Number of tokens in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens")]
public int? CompletionTokens { get; set; }
/// <summary>
/// Number of tokens in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens")]
public int? PromptTokens { get; set; }
/// <summary>
/// Total number of tokens used in the request (prompt + completion).
/// </summary>
[JsonPropertyName("total_tokens")]
public int? TotalTokens { get; set; }
/// <summary>
/// Breakdown of tokens used in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens_details")]
public required CompletionTokensDetails CompletionTokensDetails { get; set; }
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens_details")]
public required PromptTokensDetails PromptTokensDetails { get; set; }
public static CompletionUsage operator +(CompletionUsage left, CompletionUsage right) => new()
{
CompletionTokens = left.CompletionTokens + right.CompletionTokens,
PromptTokens = left.PromptTokens + right.PromptTokens,
TotalTokens = left.TotalTokens + right.TotalTokens,
CompletionTokensDetails = left.CompletionTokensDetails + right.CompletionTokensDetails,
PromptTokensDetails = left.PromptTokensDetails + right.PromptTokensDetails
};
}
/// <summary>
/// Breakdown of tokens used in a completion.
/// </summary>
internal sealed record CompletionTokensDetails
{
/// <summary>
/// When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion.
/// </summary>
[JsonPropertyName("accepted_prediction_tokens")]
public int AcceptedPredictionTokens { get; set; }
/// <summary>
/// Audio input tokens generated by the model.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Tokens generated by the model for reasoning.
/// </summary>
[JsonPropertyName("reasoning_tokens")]
public int ReasoningTokens { get; set; }
/// <summary>
/// When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion.
/// However, like reasoning tokens, these tokens are still counted in the total completion tokens for purposes of billing,
/// output, and context window limits.
/// </summary>
[JsonPropertyName("rejected_prediction_tokens")]
public int RejectedPredictionTokens { get; set; }
public static CompletionTokensDetails operator +(CompletionTokensDetails left, CompletionTokensDetails right) => new()
{
AcceptedPredictionTokens = left.AcceptedPredictionTokens + right.AcceptedPredictionTokens,
AudioTokens = left.AudioTokens + right.AudioTokens,
ReasoningTokens = left.ReasoningTokens + right.ReasoningTokens,
RejectedPredictionTokens = left.RejectedPredictionTokens + right.RejectedPredictionTokens
};
}
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
internal sealed record PromptTokensDetails
{
/// <summary>
/// Audio input tokens present in the prompt.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Cached tokens present in the prompt.
/// </summary>
[JsonPropertyName("cached_tokens")]
public int CachedTokens { get; set; }
public static PromptTokensDetails operator +(PromptTokensDetails left, PromptTokensDetails right) => new()
{
AudioTokens = left.AudioTokens + right.AudioTokens,
CachedTokens = left.CachedTokens + right.CachedTokens
};
}
@@ -0,0 +1,258 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Request to create a chat completion.
/// </summary>
internal sealed record CreateChatCompletion
{
/// <summary>
/// A list of messages comprising the conversation so far.
/// </summary>
[JsonPropertyName("messages")]
[JsonRequired]
public required IList<ChatCompletionRequestMessage> Messages { get; set; }
/// <summary>
/// Model ID used to generate the response, like `gpt-4o` or `o3`.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; set; }
/// <summary>
/// Parameters for audio output. Required when audio output is requested with modalities: ["audio"].
/// </summary>
[JsonPropertyName("audio")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Audio { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far.
/// </summary>
[JsonPropertyName("frequency_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? FrequencyPenalty { get; set; }
/// <summary>
/// Deprecated in favor of tool_choice. Controls which (if any) function is called by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of ToolChoice.")]
public object? FunctionCall { get; set; }
/// <summary>
/// Deprecated in favor of tools. A list of functions the model may generate JSON inputs for.
/// </summary>
[JsonPropertyName("functions")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of Tools.")]
public IList<object>? Functions { get; set; }
/// <summary>
/// Modify the likelihood of specified tokens appearing in the completion.
/// </summary>
[JsonPropertyName("logit_bias")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, int>? LogitBias { get; set; }
/// <summary>
/// Whether to return log probabilities of the output tokens or not.
/// </summary>
[JsonPropertyName("logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Logprobs { get; set; }
/// <summary>
/// An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens.
/// </summary>
[JsonPropertyName("max_completion_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxCompletionTokens { get; set; }
/// <summary>
/// The maximum number of tokens that can be generated in the chat completion. (Deprecated in favor of max_completion_tokens)
/// </summary>
[JsonPropertyName("max_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Use MaxCompletionTokens instead. This property is deprecated and not compatible with o-series models.")]
public int? MaxTokens { get; set; }
/// <summary>
/// Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional
/// information about the object in a structured format, and querying for objects via API or the dashboard.
/// Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters.
/// </summary>
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Metadata { get; set; }
/// <summary>
/// Types of content modalities the model can output. Can include "text" and/or "audio".
/// </summary>
[JsonPropertyName("modalities")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<string>? Modalities { get; set; }
/// <summary>
/// How many chat completion choices to generate for each input message.
/// </summary>
[JsonPropertyName("n")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? N { get; set; }
/// <summary>
/// Whether to enable parallel function calling during tool use.
/// </summary>
[JsonPropertyName("parallel_tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ParallelToolCalls { get; set; }
/// <summary>
/// Configuration for a Predicted Output, which can greatly improve response times when large parts of the model response are known ahead of time.
/// </summary>
[JsonPropertyName("prediction")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Prediction { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far.
/// </summary>
[JsonPropertyName("presence_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? PresencePenalty { get; set; }
/// <summary>
/// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates.
/// </summary>
[JsonPropertyName("prompt_cache_key")]
public string? PromptCacheKey { get; init; }
/// <summary>
/// The reasoning effort level for o-series models. Can be "low", "medium", or "high".
/// </summary>
[JsonPropertyName("reasoning_effort")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasoningEffort { get; set; }
/// <summary>
/// An object specifying the format that the model must output.
/// </summary>
[JsonPropertyName("response_format")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies.
/// The IDs should be a string that uniquely identifies each user. We recommend hashing their username or email address,
/// in order to avoid sending us any identifying information.
/// </summary>
[JsonPropertyName("safety_identifier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SafetyIdentifier { get; set; }
/// <summary>
/// If specified, the system will make a best effort to sample deterministically.
/// </summary>
[JsonPropertyName("seed")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Seed { get; set; }
/// <summary>
/// Specifies the processing type used for serving the request.
/// If set to 'auto', the request will be processed with the service tier configured in the Project settings.
/// If set to 'default', the request will be processed with standard pricing and performance.
/// If set to 'flex' or 'priority', the request will be processed with the corresponding service tier.
/// Defaults to 'auto'.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; set; }
/// <summary>
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
[JsonPropertyName("stop")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public StopSequences? Stop { get; set; }
/// <summary>
/// Whether or not to store the output of this chat completion request for use in model distillation or evals products.
/// </summary>
[JsonPropertyName("store")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Store { get; set; }
/// <summary>
/// If set to true, the model response data will be streamed to the client using server-sent events.
/// </summary>
[JsonPropertyName("stream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Stream { get; set; }
/// <summary>
/// Options for streaming response. Only set this when you set stream: true.
/// </summary>
[JsonPropertyName("stream_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? StreamOptions { get; set; }
/// <summary>
/// What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random,
/// while lower values like 0.2 will make it more focused and deterministic.
/// We generally recommend altering this or top_p but not both. Defaults to 1.
/// </summary>
[JsonPropertyName("temperature")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? Temperature { get; set; }
/// <summary>
/// Controls which (if any) tool is called by the model.
/// </summary>
[JsonPropertyName("tool_choice")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ToolChoice? ToolChoice { get; set; }
/// <summary>
/// A list of tools the model may call. Can include custom tools or function tools.
/// </summary>
[JsonPropertyName("tools")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<Tool>? Tools { get; set; }
/// <summary>
/// An integer between 0 and 20 specifying the number of most likely tokens to return at each token position.
/// </summary>
[JsonPropertyName("top_logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? TopLogprobs { get; set; }
/// <summary>
/// An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of
/// the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.
/// We generally recommend altering this or temperature but not both.
/// </summary>
[JsonPropertyName("top_p")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? TopP { get; set; }
/// <summary>
/// Level of detail in the model's output. Can be "standard" or "verbose".
/// </summary>
[JsonPropertyName("verbosity")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Verbosity { get; set; } = "medium";
/// <summary>
/// Web search tool configuration for searching the web for relevant results.
/// </summary>
[JsonPropertyName("web_search_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? WebSearchOptions { get; set; }
}
@@ -0,0 +1,167 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Content which is a part of <see cref="ChatCompletionRequestMessage"/>.
/// Can be either a string, or a list of content parts
/// </summary>
[JsonConverter(typeof(MessageContentJsonConverter))]
internal sealed record MessageContent : IEquatable<MessageContent>
{
private MessageContent(string text)
{
this.Text = text ?? throw new ArgumentNullException(nameof(text));
this.Contents = null;
}
private MessageContent(IReadOnlyList<MessageContentPart> contents)
{
this.Contents = contents ?? throw new ArgumentNullException(nameof(contents));
this.Text = null;
}
/// <summary>
/// Creates an MessageContent from a text string.
/// </summary>
public static MessageContent FromText(string text) => new(text);
/// <summary>
/// Creates an MessageContent from a list of MessageContentPart items.
/// </summary>
public static MessageContent FromContents(IReadOnlyList<MessageContentPart> contents) => new(contents);
/// <summary>
/// Creates an MessageContent from a list of MessageContentPart items.
/// </summary>
public static MessageContent FromContents(params MessageContentPart[] contents) => new(contents);
/// <summary>
/// Implicit conversion from string to MessageContent.
/// </summary>
public static implicit operator MessageContent(string text) => FromText(text);
/// <summary>
/// Implicit conversion from List to MessageContent.
/// </summary>
public static implicit operator MessageContent(List<MessageContentPart> contents) => FromContents(contents);
/// <summary>
/// Gets whether this content is text.
/// </summary>
[MemberNotNullWhen(true, nameof(Text))]
public bool IsText => this.Text is not null;
/// <summary>
/// Gets whether this content is a list of ItemContent items.
/// </summary>
[MemberNotNullWhen(true, nameof(Contents))]
public bool IsContents => this.Contents is not null;
/// <summary>
/// Gets the text value, or null if this is not text content.
/// </summary>
public string? Text { get; }
/// <summary>
/// Gets the ItemContent items, or null if this is not a content list.
/// </summary>
public IReadOnlyList<MessageContentPart>? Contents { get; }
/// <inheritdoc/>
public bool Equals(MessageContent? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
// Both text
if (this.Text is not null && other.Text is not null)
{
return this.Text == other.Text;
}
// Both contents
if (this.Contents is not null
&& other.Contents is not null
&& this.Contents.Count == other.Contents.Count)
{
return this.Contents.SequenceEqual(other.Contents);
}
// One is text, one is contents - not equal
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.Text is not null)
{
return this.Text.GetHashCode();
}
if (this.Contents is not null)
{
return this.Contents.Count > 0 ? this.Contents[0].GetHashCode() : 0;
}
return 0;
}
}
/// <summary>
/// JSON converter for <see cref="MessageContent"/>.
/// </summary>
internal sealed class MessageContentJsonConverter : JsonConverter<MessageContent>
{
public override MessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Check if it's a string
if (reader.TokenType == JsonTokenType.String)
{
var text = reader.GetString();
return text is not null ? MessageContent.FromText(text) : null;
}
// Check if it's an array of ItemContent
if (reader.TokenType == JsonTokenType.StartArray)
{
var contents = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart);
return contents?.Count > 0
? MessageContent.FromContents(contents)
: MessageContent.FromText(string.Empty);
}
throw new JsonException($"Unexpected token type for MessageContent: {reader.TokenType}");
}
public override void Write(Utf8JsonWriter writer, MessageContent value, JsonSerializerOptions options)
{
if (value.IsText)
{
writer.WriteStringValue(value.Text);
}
else if (value.IsContents)
{
JsonSerializer.Serialize(writer, value.Contents, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart);
}
else
{
throw new JsonException("MessageContent has no value");
}
}
}
@@ -0,0 +1,160 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a part of message content in a chat completion request.
/// Message content can be text, images, audio, or files.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(TextContentPart), "text")]
[JsonDerivedType(typeof(ImageContentPart), "image_url")]
[JsonDerivedType(typeof(AudioContentPart), "input_audio")]
[JsonDerivedType(typeof(FileContentPart), "file")]
internal abstract record MessageContentPart
{
/// <summary>
/// The type of the content.
/// </summary>
[JsonIgnore]
public abstract string Type { get; }
}
/// <summary>
/// A text content part in a message.
/// </summary>
internal sealed record TextContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "text";
/// <summary>
/// The text content.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; set; }
}
/// <summary>
/// An image content part in a message.
/// </summary>
internal sealed record ImageContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "image_url";
/// <summary>
/// Details about the image URL or base64-encoded image data.
/// </summary>
[JsonPropertyName("image_url")]
public required ImageUrl ImageUrl { get; set; }
/// <summary>
/// Gets the URL or base64-encoded data of the image.
/// </summary>
[JsonIgnore]
public string UrlOrData => this.ImageUrl.Url;
/// <summary>
/// Gets the URL of the image.
/// </summary>
[JsonIgnore]
public Uri Url => new(this.ImageUrl.Url);
}
/// <summary>
/// Details about an image for vision-enabled models.
/// </summary>
internal sealed record ImageUrl
{
/// <summary>
/// Either a URL of the image or the base64 encoded image data
/// </summary>
[JsonPropertyName("url")]
public required string Url { get; set; }
/// <summary>
/// Specifies the detail level of the image
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; set; }
}
/// <summary>
/// An audio content part in a message.
/// </summary>
internal sealed record AudioContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "input_audio";
/// <summary>
/// The input audio data.
/// </summary>
[JsonPropertyName("input_audio")]
public required InputAudio InputAudio { get; set; }
}
/// <summary>
/// Input audio data for audio-enabled models.
/// </summary>
internal sealed record InputAudio
{
/// <summary>
/// Base64 encoded audio data.
/// </summary>
[JsonPropertyName("data")]
public required string Data { get; set; }
/// <summary>
/// The format of the encoded audio data. Currently supports "wav" and "mp3".
/// </summary>
[JsonPropertyName("format")]
public required string Format { get; set; }
}
/// <summary>
/// A file content part in a message.
/// </summary>
internal sealed record FileContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "file";
/// <summary>
/// The input file data.
/// </summary>
[JsonPropertyName("file")]
public required InputFile File { get; set; }
}
/// <summary>
/// Input file data for file-enabled models.
/// </summary>
internal sealed record InputFile
{
/// <summary>
/// The base64 encoded file data, used when passing the file to the model as a string.
/// </summary>
[JsonPropertyName("file_data")]
public string? FileData { get; set; }
/// <summary>
/// The ID of an uploaded file to use as input.
/// </summary>
[JsonPropertyName("file_id")]
public string? FileId { get; set; }
/// <summary>
/// The name of the file, used when passing the file to the model as a string.
/// </summary>
[JsonPropertyName("filename")]
public string? Filename { get; set; }
}
@@ -0,0 +1,282 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Specifies the format that the model must output.
/// </summary>
[JsonConverter(typeof(ResponseFormatConverter))]
internal sealed record ResponseFormat : IEquatable<ResponseFormat>
{
private ResponseFormat(TextResponseFormat text)
{
this.Text = text ?? throw new ArgumentNullException(nameof(text));
this.JsonSchema = null;
this.JsonObject = null;
}
private ResponseFormat(JsonSchemaResponseFormat jsonSchema)
{
this.JsonSchema = jsonSchema ?? throw new ArgumentNullException(nameof(jsonSchema));
this.Text = null;
this.JsonObject = null;
}
private ResponseFormat(JsonObjectResponseFormat jsonObject)
{
this.JsonObject = jsonObject ?? throw new ArgumentNullException(nameof(jsonObject));
this.Text = null;
this.JsonSchema = null;
}
/// <summary>
/// Creates a ResponseFormat for text output (default).
/// </summary>
public static ResponseFormat FromText() => new(new TextResponseFormat());
/// <summary>
/// Creates a ResponseFormat for JSON Schema output with Structured Outputs.
/// </summary>
public static ResponseFormat FromJsonSchema(JsonSchemaResponseFormat jsonSchema) => new(jsonSchema);
/// <summary>
/// Creates a ResponseFormat for JSON object output (older JSON mode).
/// </summary>
public static ResponseFormat FromJsonObject() => new(new JsonObjectResponseFormat());
/// <summary>
/// Gets whether this is a text response format.
/// </summary>
[MemberNotNullWhen(true, nameof(Text))]
public bool IsText => this.Text is not null;
/// <summary>
/// Gets whether this is a JSON schema response format.
/// </summary>
[MemberNotNullWhen(true, nameof(JsonSchema))]
public bool IsJsonSchema => this.JsonSchema is not null;
/// <summary>
/// Gets whether this is a JSON object response format.
/// </summary>
[MemberNotNullWhen(true, nameof(JsonObject))]
public bool IsJsonObject => this.JsonObject is not null;
/// <summary>
/// Gets the text response format, or null if this is not a text format.
/// </summary>
public TextResponseFormat? Text { get; }
/// <summary>
/// Gets the JSON schema response format, or null if this is not a JSON schema format.
/// </summary>
public JsonSchemaResponseFormat? JsonSchema { get; }
/// <summary>
/// Gets the JSON object response format, or null if this is not a JSON object format.
/// </summary>
public JsonObjectResponseFormat? JsonObject { get; }
/// <inheritdoc/>
public bool Equals(ResponseFormat? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
if (this.Text is not null && other.Text is not null)
{
return this.Text.Equals(other.Text);
}
if (this.JsonSchema is not null && other.JsonSchema is not null)
{
return this.JsonSchema.Equals(other.JsonSchema);
}
if (this.JsonObject is not null && other.JsonObject is not null)
{
return this.JsonObject.Equals(other.JsonObject);
}
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.Text is not null)
{
return this.Text.GetHashCode();
}
if (this.JsonSchema is not null)
{
return this.JsonSchema.GetHashCode();
}
if (this.JsonObject is not null)
{
return this.JsonObject.GetHashCode();
}
return 0;
}
}
/// <summary>
/// Text response format. Default response format used to generate text responses.
/// </summary>
internal sealed record TextResponseFormat
{
/// <summary>
/// The type of response format. Always "text".
/// </summary>
[JsonPropertyName("type")]
public string Type => "text";
}
/// <summary>
/// JSON Schema response format. Used to generate structured JSON responses with Structured Outputs.
/// </summary>
internal sealed record JsonSchemaResponseFormat
{
/// <summary>
/// The type of response format. Always "json_schema".
/// </summary>
[JsonPropertyName("type")]
public string Type => "json_schema";
/// <summary>
/// Structured Outputs configuration options, including a JSON Schema.
/// </summary>
[JsonPropertyName("json_schema")]
[JsonRequired]
public required JsonSchemaConfiguration JsonSchema { get; init; }
}
/// <summary>
/// Configuration for JSON Schema Structured Outputs.
/// </summary>
internal sealed record JsonSchemaConfiguration
{
/// <summary>
/// The name of the schema.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// A description of the schema.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The JSON Schema definition.
/// </summary>
[JsonPropertyName("schema")]
[JsonRequired]
public required JsonElement Schema { get; init; }
/// <summary>
/// Whether to enable strict schema adherence.
/// </summary>
[JsonPropertyName("strict")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Strict { get; init; }
}
/// <summary>
/// JSON object response format. An older method of generating JSON responses.
/// Using json_schema is recommended for models that support it.
/// </summary>
internal sealed record JsonObjectResponseFormat
{
/// <summary>
/// The type of response format. Always "json_object".
/// </summary>
[JsonPropertyName("type")]
public string Type => "json_object";
}
/// <summary>
/// JSON converter for <see cref="ResponseFormat"/> that handles different response format types.
/// </summary>
internal sealed class ResponseFormatConverter : JsonConverter<ResponseFormat>
{
/// <inheritdoc/>
public override ResponseFormat? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.StartObject)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
if (root.TryGetProperty("type", out var typeProperty))
{
var type = typeProperty.GetString();
return type switch
{
"text" => ResponseFormat.FromText(),
"json_schema" => ResponseFormat.FromJsonSchema(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat)!),
"json_object" => ResponseFormat.FromJsonObject(),
_ => throw new JsonException($"Unknown response format type: {type}")
};
}
throw new JsonException("Response format object must have a 'type' property.");
}
throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ResponseFormat.");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ResponseFormat? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
if (value.IsText)
{
JsonSerializer.Serialize(writer, value.Text, ChatCompletionsJsonContext.Default.TextResponseFormat);
}
else if (value.IsJsonSchema)
{
JsonSerializer.Serialize(writer, value.JsonSchema, ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat);
}
else if (value.IsJsonObject)
{
JsonSerializer.Serialize(writer, value.JsonObject, ChatCompletionsJsonContext.Default.JsonObjectResponseFormat);
}
else
{
writer.WriteNullValue();
}
}
}
@@ -0,0 +1,193 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents stop sequences for chat completion generation.
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
[JsonConverter(typeof(StopSequencesConverter))]
internal sealed record StopSequences : IEquatable<StopSequences>
{
private StopSequences(string singleSequence)
{
this.SingleSequence = singleSequence ?? throw new ArgumentNullException(nameof(singleSequence));
this.Sequences = null;
}
private StopSequences(IList<string> sequences)
{
if (sequences is null || sequences.Count == 0)
{
throw new ArgumentException("Sequences cannot be null or empty.", nameof(sequences));
}
if (sequences.Count > 4)
{
throw new ArgumentException("Maximum of 4 stop sequences are allowed.", nameof(sequences));
}
this.Sequences = sequences;
this.SingleSequence = null;
}
/// <summary>
/// Creates a StopSequences from a single stop sequence string.
/// </summary>
public static StopSequences FromString(string sequence) => new(sequence);
/// <summary>
/// Creates a StopSequences from a list of stop sequences.
/// </summary>
public static StopSequences FromSequences(IList<string> sequences) => new(sequences);
/// <summary>
/// Implicit conversion from string to StopSequences.
/// </summary>
public static implicit operator StopSequences(string sequence) => FromString(sequence);
/// <summary>
/// Implicit conversion from string array to StopSequences.
/// </summary>
public static implicit operator StopSequences(string[] sequences) => FromSequences(sequences);
/// <summary>
/// Implicit conversion from List to StopSequences.
/// </summary>
public static implicit operator StopSequences(List<string> sequences) => FromSequences(sequences);
/// <summary>
/// Gets whether this is a single stop sequence.
/// </summary>
[MemberNotNullWhen(true, nameof(SingleSequence))]
public bool IsSingleSequence => this.SingleSequence is not null;
/// <summary>
/// Gets whether this contains multiple stop sequences.
/// </summary>
[MemberNotNullWhen(true, nameof(Sequences))]
public bool IsSequences => this.Sequences is not null;
/// <summary>
/// Gets the single stop sequence, or null if this contains multiple sequences.
/// </summary>
public string? SingleSequence { get; }
/// <summary>
/// Gets the list of stop sequences, or null if this is a single sequence.
/// </summary>
public IList<string>? Sequences { get; }
public IList<string> SequenceList =>
this.IsSingleSequence ? [this.SingleSequence] :
this.IsSequences ? this.Sequences : [];
/// <inheritdoc/>
public bool Equals(StopSequences? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
// Both single sequences
if (this.SingleSequence is not null && other.SingleSequence is not null)
{
return this.SingleSequence == other.SingleSequence;
}
// Both sequences
if (this.Sequences is not null && other.Sequences is not null)
{
return this.Sequences.SequenceEqual(other.Sequences);
}
// One is single, one is sequences - not equal
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.SingleSequence is not null)
{
return this.SingleSequence.GetHashCode();
}
if (this.Sequences is not null)
{
return this.Sequences.Count > 0 ? this.Sequences[0].GetHashCode() : 0;
}
return 0;
}
}
/// <summary>
/// JSON converter for <see cref="StopSequences"/> that handles string, array, and null representations.
/// </summary>
internal sealed class StopSequencesConverter : JsonConverter<StopSequences>
{
/// <inheritdoc/>
public override StopSequences? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Handle null
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
// Handle single string
if (reader.TokenType == JsonTokenType.String)
{
string? sequence = reader.GetString();
return sequence is not null ? StopSequences.FromString(sequence) : null;
}
// Handle array of strings
if (reader.TokenType == JsonTokenType.StartArray)
{
var sequences = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IListString);
return sequences?.Count > 0
? StopSequences.FromSequences(sequences)
: StopSequences.FromString(string.Empty);
}
throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing StopSequences. Expected String, StartArray, or Null.");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, StopSequences? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
if (value.IsSingleSequence)
{
writer.WriteStringValue(value.SingleSequence);
}
else if (value.IsSequences)
{
JsonSerializer.Serialize(writer, value.Sequences, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart);
}
else
{
writer.WriteNullValue();
}
}
}
@@ -0,0 +1,164 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a tool that the model may call. Can be either a function tool or a custom tool.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(FunctionTool), "function")]
[JsonDerivedType(typeof(CustomTool), "custom")]
internal abstract record Tool
{
/// <summary>
/// The type of the tool.
/// </summary>
[JsonPropertyName("type")]
public abstract string Type { get; }
}
/// <summary>
/// A function tool that can be used to generate a response.
/// </summary>
internal sealed record FunctionTool : Tool
{
/// <summary>
/// The type of the tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
public override string Type => "function";
/// <summary>
/// The function definition.
/// </summary>
[JsonPropertyName("function")]
[JsonRequired]
public required FunctionDefinition Function { get; init; }
}
/// <summary>
/// Definition of a function that can be called by the model.
/// </summary>
internal sealed record FunctionDefinition
{
/// <summary>
/// The name of the function to be called.
/// Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// A description of what the function does, used by the model to choose when and how to call the function.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The parameters the function accepts, described as a JSON Schema object.
/// Omitting parameters defines a function with an empty parameter list.
/// </summary>
[JsonPropertyName("parameters")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Parameters { get; init; }
/// <summary>
/// Whether to enable strict schema adherence when generating the function call.
/// If set to true, the model will follow the exact schema defined in the parameters field.
/// Only a subset of JSON Schema is supported when strict is true.
/// Defaults to false.
/// </summary>
[JsonPropertyName("strict")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Strict { get; init; }
}
/// <summary>
/// A custom tool that processes input using a specified format.
/// </summary>
internal sealed record CustomTool : Tool
{
/// <summary>
/// The type of the tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
public override string Type => "custom";
/// <summary>
/// Properties of the custom tool.
/// </summary>
[JsonPropertyName("custom")]
[JsonRequired]
public required CustomToolProperties Custom { get; init; }
}
/// <summary>
/// A wrapper for MEAI <see cref="AITool"/>
/// </summary>
internal sealed class CustomAITool : AITool
{
public CustomAITool(string name, string? description, IReadOnlyDictionary<string, object?>? additionalProperties)
: base()
{
this.Name = name;
this.Description = description ?? string.Empty;
this.AdditionalProperties = additionalProperties ?? new Dictionary<string, object?>();
}
public override string Name { get; }
public override string Description { get; }
public override IReadOnlyDictionary<string, object?> AdditionalProperties { get; }
}
/// <summary>
/// Properties of a custom tool.
/// </summary>
internal sealed record CustomToolProperties
{
/// <summary>
/// The name of the custom tool, used to identify it in tool calls.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// Optional description of the custom tool, used to provide more context.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The input format for the custom tool. Default is unconstrained text.
/// </summary>
[JsonPropertyName("format")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CustomToolFormat? Format { get; init; }
}
/// <summary>
/// The input format for a custom tool.
/// </summary>
internal sealed record CustomToolFormat
{
/// <summary>
/// The type of format. Can be various schema types.
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Type { get; init; }
/// <summary>
/// Additional format properties (schema definition).
/// </summary>
[JsonExtensionData]
public Dictionary<string, object?>? AdditionalProperties { get; init; }
}
@@ -0,0 +1,384 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Controls which (if any) tool is called by the model.
/// </summary>
[JsonConverter(typeof(ToolChoiceConverter))]
internal sealed record ToolChoice : IEquatable<ToolChoice>
{
private ToolChoice(string mode)
{
this.Mode = mode ?? throw new ArgumentNullException(nameof(mode));
this.AllowedTools = null;
this.FunctionTool = null;
this.CustomTool = null;
}
private ToolChoice(AllowedToolsChoice allowedTools)
{
this.AllowedTools = allowedTools ?? throw new ArgumentNullException(nameof(allowedTools));
this.Mode = null;
this.FunctionTool = null;
this.CustomTool = null;
}
private ToolChoice(FunctionToolChoice functionTool)
{
this.FunctionTool = functionTool ?? throw new ArgumentNullException(nameof(functionTool));
this.Mode = null;
this.AllowedTools = null;
this.CustomTool = null;
}
private ToolChoice(CustomToolChoice customTool)
{
this.CustomTool = customTool ?? throw new ArgumentNullException(nameof(customTool));
this.Mode = null;
this.AllowedTools = null;
this.FunctionTool = null;
}
/// <summary>
/// Creates a ToolChoice from a mode string ("none", "auto", or "required").
/// </summary>
public static ToolChoice FromMode(string mode) => new(mode);
/// <summary>
/// Creates a ToolChoice that constrains tools to a pre-defined set.
/// </summary>
public static ToolChoice FromAllowedTools(AllowedToolsChoice allowedTools) => new(allowedTools);
/// <summary>
/// Creates a ToolChoice that forces the model to call a specific function.
/// </summary>
public static ToolChoice FromFunction(FunctionToolChoice functionTool) => new(functionTool);
/// <summary>
/// Creates a ToolChoice that forces the model to call a specific custom tool.
/// </summary>
public static ToolChoice FromCustom(CustomToolChoice customTool) => new(customTool);
/// <summary>
/// Implicit conversion from string to ToolChoice.
/// </summary>
public static implicit operator ToolChoice(string mode) => FromMode(mode);
/// <summary>
/// Gets whether this is a mode string.
/// </summary>
[MemberNotNullWhen(true, nameof(Mode))]
public bool IsMode => this.Mode is not null;
/// <summary>
/// Gets whether this is an allowed tools configuration.
/// </summary>
[MemberNotNullWhen(true, nameof(AllowedTools))]
public bool IsAllowedTools => this.AllowedTools is not null;
/// <summary>
/// Gets whether this is a function tool choice.
/// </summary>
[MemberNotNullWhen(true, nameof(FunctionTool))]
public bool IsFunctionTool => this.FunctionTool is not null;
/// <summary>
/// Gets whether this is a custom tool choice.
/// </summary>
[MemberNotNullWhen(true, nameof(CustomTool))]
public bool IsCustomTool => this.CustomTool is not null;
/// <summary>
/// Gets the mode string, or null if this is not a mode.
/// </summary>
public string? Mode { get; }
/// <summary>
/// Gets the allowed tools configuration, or null if this is not an allowed tools choice.
/// </summary>
public AllowedToolsChoice? AllowedTools { get; }
/// <summary>
/// Gets the function tool choice, or null if this is not a function tool choice.
/// </summary>
public FunctionToolChoice? FunctionTool { get; }
/// <summary>
/// Gets the custom tool choice, or null if this is not a custom tool choice.
/// </summary>
public CustomToolChoice? CustomTool { get; }
/// <inheritdoc/>
public bool Equals(ToolChoice? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
if (this.Mode is not null && other.Mode is not null)
{
return this.Mode == other.Mode;
}
if (this.AllowedTools is not null && other.AllowedTools is not null)
{
return this.AllowedTools.Equals(other.AllowedTools);
}
if (this.FunctionTool is not null && other.FunctionTool is not null)
{
return this.FunctionTool.Equals(other.FunctionTool);
}
if (this.CustomTool is not null && other.CustomTool is not null)
{
return this.CustomTool.Equals(other.CustomTool);
}
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.Mode is not null)
{
return this.Mode.GetHashCode();
}
if (this.AllowedTools is not null)
{
return this.AllowedTools.GetHashCode();
}
if (this.FunctionTool is not null)
{
return this.FunctionTool.GetHashCode();
}
if (this.CustomTool is not null)
{
return this.CustomTool.GetHashCode();
}
return 0;
}
}
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// </summary>
internal sealed record AllowedToolsChoice
{
/// <summary>
/// The type of tool choice. Always "allowed_tools".
/// </summary>
[JsonPropertyName("type")]
public string Type => "allowed_tools";
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// </summary>
[JsonPropertyName("allowed_tools")]
[JsonRequired]
public required AllowedToolsConfiguration AllowedTools { get; init; }
}
/// <summary>
/// Configuration for allowed tools.
/// </summary>
internal sealed record AllowedToolsConfiguration
{
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// auto allows the model to pick from among the allowed tools and generate a message.
/// required requires the model to call one or more of the allowed tools.
/// </summary>
[JsonPropertyName("mode")]
[JsonRequired]
public required string Mode { get; init; }
/// <summary>
/// A list of tool definitions that the model should be allowed to call.
/// </summary>
[JsonPropertyName("tools")]
[JsonRequired]
public required IList<ToolDefinition> Tools { get; init; }
}
/// <summary>
/// A tool definition in the allowed tools list.
/// </summary>
internal sealed record ToolDefinition
{
/// <summary>
/// The type of tool (e.g., "function" or "custom").
/// </summary>
[JsonPropertyName("type")]
[JsonRequired]
public required string Type { get; init; }
/// <summary>
/// The function details if type is "function".
/// </summary>
[JsonPropertyName("function")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public FunctionReference? Function { get; init; }
}
/// <summary>
/// A reference to a function by name.
/// </summary>
internal sealed record FunctionReference
{
/// <summary>
/// The name of the function.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
}
/// <summary>
/// Specifies a function tool the model should use.
/// </summary>
internal sealed record FunctionToolChoice
{
/// <summary>
/// The type of tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
public string Type => "function";
/// <summary>
/// The function to call.
/// </summary>
[JsonPropertyName("function")]
[JsonRequired]
public required FunctionReference Function { get; init; }
}
/// <summary>
/// Specifies a custom tool the model should use.
/// </summary>
internal sealed record CustomToolChoice
{
/// <summary>
/// The type of tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
public string Type => "custom";
/// <summary>
/// The custom tool configuration.
/// </summary>
[JsonPropertyName("custom")]
[JsonRequired]
public required CustomToolObject Custom { get; init; }
}
/// <summary>
/// A reference to a custom tool object.
/// </summary>
internal sealed record CustomToolObject
{
/// <summary>
/// The name of the function.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
}
/// <summary>
/// JSON converter for <see cref="ToolChoice"/> that handles string and object representations.
/// </summary>
internal sealed class ToolChoiceConverter : JsonConverter<ToolChoice>
{
/// <inheritdoc/>
public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.String)
{
string? mode = reader.GetString();
return mode is not null ? ToolChoice.FromMode(mode) : null;
}
if (reader.TokenType == JsonTokenType.StartObject)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
if (root.TryGetProperty("type", out var typeProperty))
{
var type = typeProperty.GetString();
return type switch
{
"allowed_tools" => ToolChoice.FromAllowedTools(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.AllowedToolsChoice)!),
"function" => ToolChoice.FromFunction(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.FunctionToolChoice)!),
"custom" => ToolChoice.FromCustom(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.CustomToolChoice)!),
_ => throw new JsonException($"Unknown tool choice type: {type}")
};
}
throw new JsonException("Tool choice object must have a 'type' property.");
}
throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ToolChoice.");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ToolChoice? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
if (value.IsMode)
{
writer.WriteStringValue(value.Mode);
}
else if (value.IsAllowedTools)
{
JsonSerializer.Serialize(writer, value.AllowedTools, ChatCompletionsJsonContext.Default.AllowedToolsChoice);
}
else if (value.IsFunctionTool)
{
JsonSerializer.Serialize(writer, value.FunctionTool, ChatCompletionsJsonContext.Default.FunctionToolChoice);
}
else if (value.IsCustomTool)
{
JsonSerializer.Serialize(writer, value.CustomTool, ChatCompletionsJsonContext.Default.CustomToolChoice);
}
else
{
writer.WriteNullValue();
}
}
}
@@ -1,52 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Shared.Diagnostics;
using OpenAI.Chat;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils;
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")]
internal static class ChatCompletionsOptionsExtensions
{
private static readonly Func<ChatCompletionOptions, bool?> s_getStreamNullable;
private static readonly Func<ChatCompletionOptions, IList<ChatMessage>> s_getMessages;
static ChatCompletionsOptionsExtensions()
{
// OpenAI SDK does not have a simple way to get the input as a c# object.
// However, it does parse most of the interesting fields into internal properties of `ChatCompletionsOptions` object.
// --- Stream (internal bool? Stream { get; set; }) ---
const string StreamPropName = "Stream";
var streamProp = typeof(ChatCompletionOptions).GetProperty(StreamPropName, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, StreamPropName);
var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{StreamPropName} getter not found.");
s_getStreamNullable = streamGetter.CreateDelegate<Func<ChatCompletionOptions, bool?>>();
// --- Messages (internal IList<OpenAI.Chat.ChatMessage> Messages { get; set; }) ---
const string InputPropName = "Messages";
var inputProp = typeof(ChatCompletionOptions).GetProperty(InputPropName, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, InputPropName);
var inputGetter = inputProp.GetGetMethod(nonPublic: true)
?? throw new MissingMethodException($"{InputPropName} getter not found.");
s_getMessages = inputGetter.CreateDelegate<Func<ChatCompletionOptions, IList<ChatMessage>>>();
}
public static IList<ChatMessage> GetMessages(this ChatCompletionOptions options)
{
Throw.IfNull(options);
return s_getMessages(options);
}
public static bool GetStream(this ChatCompletionOptions options)
{
Throw.IfNull(options);
return s_getStreamNullable(options) ?? false;
}
}
@@ -1,16 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
using Microsoft.AspNetCore.Http;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using OpenAI.Chat;
namespace Microsoft.AspNetCore.Builder;
@@ -20,47 +19,54 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agentName">The name of the AI agent service registered in the dependency injection container. This name is used to resolve the <see cref="AIAgent"/> instance from the keyed services.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI ChatCompletions endpoints for.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder)
=> MapOpenAIChatCompletions(endpoints, agentBuilder, path: null);
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
public static void MapOpenAIChatCompletions(
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentBuilder.Name);
return MapOpenAIChatCompletions(endpoints, agent, path);
}
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI ChatCompletions endpoints for.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, AIAgent agent)
=> MapOpenAIChatCompletions(endpoints, agent, path: null);
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(
this IEndpointRouteBuilder endpoints,
string agentName,
[StringSyntax("Route")] string? path = null)
AIAgent agent,
[StringSyntax("Route")] string? path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentName);
if (path is null)
{
ValidateAgentName(agentName);
}
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent.Name));
ValidateAgentName(agent.Name);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
path ??= $"/{agentName}/v1/chat/completions";
var chatCompletionsRouteGroup = endpoints.MapGroup(path);
MapChatCompletions(chatCompletionsRouteGroup, agent);
}
private static void MapChatCompletions(IEndpointRouteBuilder routeGroup, AIAgent agent)
{
path ??= $"/{agent.Name}/v1/chat/completions";
var group = endpoints.MapGroup(path);
var endpointAgentName = agent.DisplayName;
var chatCompletionsProcessor = new AIAgentChatCompletionsProcessor(agent);
routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) =>
{
var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false);
group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken)
=> await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false))
.WithName(endpointAgentName + "/CreateChatCompletion");
var chatCompletionOptions = new ChatCompletionOptions();
var chatCompletionOptionsJsonModel = chatCompletionOptions as IJsonModel<ChatCompletionOptions>;
Debug.Assert(chatCompletionOptionsJsonModel is not null);
chatCompletionOptions = chatCompletionOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json);
if (chatCompletionOptions is null)
{
return Results.BadRequest("Invalid request payload.");
}
return await chatCompletionsProcessor.CreateChatCompletionAsync(chatCompletionOptions, cancellationToken).ConfigureAwait(false);
}).WithName(endpointAgentName + "/CreateChatCompletion");
return group;
}
}
@@ -8,10 +8,24 @@ using Microsoft.Extensions.Hosting;
namespace Microsoft.Extensions.Hosting;
/// <summary>
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure OpenAI Responses support.
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure OpenAI support.
/// </summary>
public static class MicrosoftAgentAIHostingOpenAIHostApplicationBuilderExtensions
{
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI ChatCompletions.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddOpenAIChatCompletions(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOpenAIChatCompletions();
return builder;
}
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// </summary>
@@ -0,0 +1,98 @@
// 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..];
}
}
@@ -1,8 +1,5 @@
// 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.Responses;
@@ -14,14 +11,6 @@ internal sealed partial class IdGenerator
{
private readonly string _partitionId;
#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>
@@ -29,9 +18,9 @@ internal sealed partial class IdGenerator
/// <param name="conversationId">The conversation ID.</param>
public IdGenerator(string? responseId, string? conversationId)
{
this.ResponseId = responseId ?? NewId("resp");
this.ConversationId = conversationId ?? NewId("conv");
this._partitionId = GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty;
this.ResponseId = responseId ?? IdGeneratorHelpers.NewId("resp");
this.ConversationId = conversationId ?? IdGeneratorHelpers.NewId("conv");
this._partitionId = IdGeneratorHelpers.GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty;
}
/// <summary>
@@ -64,7 +53,7 @@ internal sealed partial class IdGenerator
public string Generate(string? category = null)
{
var prefix = string.IsNullOrEmpty(category) ? "id" : category;
return NewId(prefix, partitionKey: this._partitionId);
return IdGeneratorHelpers.NewId(prefix, partitionKey: this._partitionId);
}
/// <summary>
@@ -90,81 +79,4 @@ internal sealed partial class IdGenerator
/// </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>
/// <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>
private 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>
private 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>
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..];
}
}
@@ -2,16 +2,31 @@
using System;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.AspNetCore.Http.Json;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAI Responses support.
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAI support.
/// </summary>
public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions
{
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI ChatCompletions.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddOpenAIChatCompletions(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.Configure<JsonOptions>(options => options.SerializerOptions.TypeInfoResolverChain.Add(ChatCompletionsJsonSerializerOptions.Default.TypeInfoResolver!));
return services;
}
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// </summary>
@@ -26,8 +26,10 @@ public static class HostedWorkflowBuilderExtensions
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name)
{
var agentName = name ?? builder.Name;
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) => sp.GetRequiredKeyedService<Workflow>(key)
.AsAgent(name: key));
var workflowName = builder.Name;
var agentName = name ?? workflowName;
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAgent(name: key));
}
}
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -31,10 +32,15 @@ public sealed class Mem0Provider : AIContextProvider
private readonly Mem0Client _client;
private readonly ILogger<Mem0Provider>? _logger;
private readonly Mem0ProviderScope _storageScope;
private readonly Mem0ProviderScope _searchScope;
/// <summary>
/// Initializes a new instance of the <see cref="Mem0Provider"/> class.
/// </summary>
/// <param name="httpClient">Configured <see cref="HttpClient"/> (base address + auth).</param>
/// <param name="storageScope">Optional values to scope the memory storage with.</param>
/// <param name="searchScope">Optional values to scope the memory search with. Defaults to <paramref name="storageScope"/> if not provided.</param>
/// <param name="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <remarks>
@@ -47,21 +53,35 @@ public sealed class Mem0Provider : AIContextProvider
/// new Mem0AIContextProvider(httpClient);
/// </code>
/// </remarks>
public Mem0Provider(HttpClient httpClient, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
public Mem0Provider(HttpClient httpClient, Mem0ProviderScope storageScope, Mem0ProviderScope? searchScope = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri))
{
throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient));
}
this.ApplicationId = options?.ApplicationId;
this.AgentId = options?.AgentId;
this.ThreadId = options?.ThreadId;
this.UserId = options?.UserId;
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
this._client = new Mem0Client(httpClient);
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
this._storageScope = new Mem0ProviderScope(Throw.IfNull(storageScope));
this._searchScope = searchScope ?? storageScope;
if (string.IsNullOrWhiteSpace(this._storageScope.ApplicationId)
&& string.IsNullOrWhiteSpace(this._storageScope.AgentId)
&& string.IsNullOrWhiteSpace(this._storageScope.ThreadId)
&& string.IsNullOrWhiteSpace(this._storageScope.UserId))
{
throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope.");
}
if (string.IsNullOrWhiteSpace(this._searchScope.ApplicationId)
&& string.IsNullOrWhiteSpace(this._searchScope.AgentId)
&& string.IsNullOrWhiteSpace(this._searchScope.ThreadId)
&& string.IsNullOrWhiteSpace(this._searchScope.UserId))
{
throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope.");
}
}
/// <summary>
@@ -70,6 +90,7 @@ public sealed class Mem0Provider : AIContextProvider
/// <param name="httpClient">Configured <see cref="HttpClient"/> (base address + auth).</param>
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the store.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <param name="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentException"></exception>
/// <remarks>
@@ -82,46 +103,30 @@ public sealed class Mem0Provider : AIContextProvider
/// new Mem0AIContextProvider(httpClient, state);
/// </code>
/// </remarks>
public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, ILoggerFactory? loggerFactory = null)
public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri))
{
throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient));
}
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
this._client = new Mem0Client(httpClient);
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions;
var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(Mem0State))) as Mem0State;
this.ApplicationId = state?.ApplicationId;
this.AgentId = state?.AgentId;
this.ThreadId = state?.ThreadId;
this.UserId = state?.UserId;
this._contextPrompt = state?.ContextPrompt ?? DefaultContextPrompt;
if (state == null || state.StorageScope == null || state.SearchScope == null)
{
throw new InvalidOperationException("The Mem0Provider state did not contain the required scope properties.");
}
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
this._client = new Mem0Client(httpClient);
this._storageScope = state.StorageScope;
this._searchScope = state.SearchScope;
}
/// <summary>
/// Gets or sets an optional ID for the application to scope memories to.
/// </summary>
public string? ApplicationId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the agent to scope memories to.
/// </summary>
public string? AgentId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the thread to scope memories to.
/// </summary>
public string? ThreadId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the user to scope memories to.
/// </summary>
public string? UserId { get; set; }
/// <inheritdoc />
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
@@ -134,10 +139,10 @@ public sealed class Mem0Provider : AIContextProvider
try
{
var memories = (await this._client.SearchAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId,
queryText,
cancellationToken).ConfigureAwait(false)).ToList();
@@ -150,20 +155,20 @@ public sealed class Mem0Provider : AIContextProvider
this._logger.LogInformation(
"Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
memories.Count,
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId);
if (outputMessageText is not null)
{
this._logger.LogTrace(
"Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
queryText,
outputMessageText,
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId);
}
}
@@ -181,10 +186,10 @@ public sealed class Mem0Provider : AIContextProvider
this._logger?.LogError(
ex,
"Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._searchScope.ApplicationId,
this._searchScope.AgentId,
this._searchScope.ThreadId,
this._searchScope.UserId);
return new AIContext();
}
}
@@ -207,10 +212,10 @@ public sealed class Mem0Provider : AIContextProvider
this._logger?.LogError(
ex,
"Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
this._storageScope.ApplicationId,
this._storageScope.AgentId,
this._storageScope.ThreadId,
this._storageScope.UserId);
}
}
@@ -220,23 +225,16 @@ public sealed class Mem0Provider : AIContextProvider
/// <param name="cancellationToken">Cancellation token.</param>
public Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) =>
this._client.ClearMemoryAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
this._storageScope.ApplicationId,
this._storageScope.AgentId,
this._storageScope.ThreadId,
this._storageScope.UserId,
cancellationToken);
/// <inheritdoc />
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var state = new Mem0State
{
ApplicationId = this.ApplicationId,
AgentId = this.AgentId,
ThreadId = this.ThreadId,
UserId = this.UserId,
ContextPrompt = this._contextPrompt == DefaultContextPrompt ? null : this._contextPrompt
};
var state = new Mem0State(this._storageScope, this._searchScope);
var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions;
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(Mem0State)));
@@ -262,10 +260,10 @@ public sealed class Mem0Provider : AIContextProvider
}
await this._client.CreateMemoryAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
this._storageScope.ApplicationId,
this._storageScope.AgentId,
this._storageScope.ThreadId,
this._storageScope.UserId,
message.Text,
message.Role.Value,
cancellationToken).ConfigureAwait(false);
@@ -274,10 +272,14 @@ public sealed class Mem0Provider : AIContextProvider
internal sealed class Mem0State
{
public string? ApplicationId { get; set; }
public string? AgentId { get; set; }
public string? UserId { get; set; }
public string? ThreadId { get; set; }
public string? ContextPrompt { get; set; }
[JsonConstructor]
public Mem0State(Mem0ProviderScope storageScope, Mem0ProviderScope searchScope)
{
this.StorageScope = storageScope;
this.SearchScope = searchScope;
}
public Mem0ProviderScope StorageScope { get; set; }
public Mem0ProviderScope SearchScope { get; set; }
}
}
@@ -5,35 +5,8 @@ namespace Microsoft.Agents.AI.Mem0;
/// <summary>
/// Options for configuring the <see cref="Mem0Provider"/>.
/// </summary>
/// <remarks>
/// Mem0 memories can be scoped by one or more of: application, agent, thread, and user.
/// At least one scope must be provided; otherwise Mem0 will reject requests.
/// </remarks>
public sealed class Mem0ProviderOptions
{
/// <summary>
/// Gets or sets an optional ID for the application to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all applications.</remarks>
public string? ApplicationId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the agent to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all agents.</remarks>
public string? AgentId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the thread to scope memories to.
/// </summary>
public string? ThreadId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the user to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all users.</remarks>
public string? UserId { get; set; }
/// <summary>
/// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
/// </summary>
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Mem0;
/// <summary>
/// Allows scoping of memories for the <see cref="Mem0Provider"/>.
/// </summary>
/// <remarks>
/// Mem0 memories can be scoped by one or more of: application, agent, thread, and user.
/// At least one scope must be provided; otherwise Mem0 will reject requests.
/// </remarks>
public sealed class Mem0ProviderScope
{
/// <summary>
/// Initializes a new instance of the <see cref="Mem0ProviderScope"/> class.
/// </summary>
public Mem0ProviderScope() { }
/// <summary>
/// Initializes a new instance of the <see cref="Mem0ProviderScope"/> class by cloning an existing scope.
/// </summary>
/// <param name="sourceScope">The scope to clone.</param>
public Mem0ProviderScope(Mem0ProviderScope sourceScope)
{
Throw.IfNull(sourceScope);
this.ApplicationId = sourceScope.ApplicationId;
this.AgentId = sourceScope.AgentId;
this.ThreadId = sourceScope.ThreadId;
this.UserId = sourceScope.UserId;
}
/// <summary>
/// Gets or sets an optional ID for the application to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all applications.</remarks>
public string? ApplicationId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the agent to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all agents.</remarks>
public string? AgentId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the thread to scope memories to.
/// </summary>
public string? ThreadId { get; set; }
/// <summary>
/// Gets or sets an optional ID for the user to scope memories to.
/// </summary>
/// <remarks>If not set, the scope of the memories will span all users.</remarks>
public string? UserId { get; set; }
}
@@ -35,7 +35,7 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
<ItemGroup>
@@ -2,10 +2,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.Execution;
@@ -15,6 +17,9 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
/// </summary>
internal sealed class StreamingRunEventStream : IRunEventStream
{
private static readonly string s_namespace = typeof(StreamingRunEventStream).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
private readonly Channel<WorkflowEvent> _eventChannel;
private readonly ISuperStepRunner _stepRunner;
private readonly InputWaiter _inputWaiter;
@@ -58,6 +63,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Subscribe to events - they will flow directly to the channel as they're raised
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun);
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId);
try
{
// Wait for the first input before starting
@@ -65,6 +73,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
while (!linkedSource.Token.IsCancellationRequested)
{
@@ -99,9 +108,17 @@ internal sealed class StreamingRunEventStream : IRunEventStream
{
// Expected during shutdown
}
catch (Exception e)
catch (Exception ex)
{
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(e), linkedSource.Token).ConfigureAwait(false);
if (activity != null)
{
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.BuildErrorMessage, ex.Message },
}));
activity.CaptureException(ex);
}
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
}
finally
{
@@ -110,6 +127,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Mark as ended when run loop exits
this._runStatus = RunStatus.Ended;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
}
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
@@ -44,82 +44,72 @@ public sealed class TextSearchProvider : AIContextProvider
private readonly ILogger<TextSearchProvider>? _logger;
private readonly AITool[] _tools;
private readonly Queue<string> _recentMessagesText;
private readonly TextSearchProviderOptions _options;
private readonly List<ChatRole> _recentMessageRolesIncluded;
private readonly int _recentMessageMemoryLimit;
private readonly TextSearchProviderOptions.TextSearchBehavior _searchTime;
private readonly string _contextPrompt;
private readonly string _citationsPrompt;
private readonly Func<IList<TextSearchResult>, string>? _contextFormatter;
/// <summary>
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class.
/// </summary>
/// <param name="searchAsync">Delegate that executes the search logic. Must not be <see langword="null"/>.</param>
/// <param name="options">Optional configuration options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="searchAsync"/> is <see langword="null"/>.</exception>
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
this._options = options ?? new();
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
this._recentMessagesText = new();
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
this._tools =
[
AIFunctionFactory.Create(
this.SearchAsync,
name: this._options.FunctionToolName ?? DefaultPluginSearchFunctionName,
description: this._options.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription)
];
}
/// <summary>
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class from previously serialized state.
/// </summary>
/// <param name="searchAsync">Delegate that executes the search logic. Must not be <see langword="null"/>.</param>
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized provider state.</param>
/// <param name="jsonSerializerOptions">Optional serializer options (unused - source generated context is used).</param>
/// <param name="options">Optional configuration options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="searchAsync"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Only overridden prompts (function name, function description, context prompt, citations prompt) are restored.
/// If a value was not persisted or matches the defaults it will fall back to the built-in defaults.
/// Custom <see cref="TextSearchProviderOptions.ContextFormatter"/> delegates are not serialized.
/// </remarks>
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
public TextSearchProvider(
Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync,
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions = null,
TextSearchProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
{
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
this._options = options ?? new();
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
// Validate and assign parameters
this._searchAsync = Throw.IfNull(searchAsync);
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
this._recentMessageMemoryLimit = Throw.IfLessThan(options?.RecentMessageMemoryLimit ?? 0, 0);
this._recentMessageRolesIncluded = options?.RecentMessageRolesIncluded ?? [ChatRole.User];
this._searchTime = options?.SearchTime ?? TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke;
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
this._citationsPrompt = options?.CitationsPrompt ?? DefaultCitationsPrompt;
this._contextFormatter = options?.ContextFormatter;
// Restore recent messages from serialized state if provided
List<string>? restoredMessages = null;
var state = serializedState.Deserialize(AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(TextSearchProviderState))) as TextSearchProviderState;
if (state?.RecentMessagesText is { Count: > 0 })
if (serializedState.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
restoredMessages = state.RecentMessagesText;
this._recentMessagesText = new();
}
else
{
var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(TextSearchProviderState))) as TextSearchProviderState;
if (state?.RecentMessagesText is { Count: > 0 })
{
restoredMessages = state.RecentMessagesText;
}
// Restore recent messages respecting the limit (may truncate if limit changed afterwards).
this._recentMessagesText = restoredMessages is null ? new() : new(restoredMessages.Take(this._options.RecentMessageMemoryLimit));
// Restore recent messages respecting the limit (may truncate if limit changed afterwards).
this._recentMessagesText = restoredMessages is null ? new() : new(restoredMessages.Take(this._recentMessageMemoryLimit));
}
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
this._tools =
[
AIFunctionFactory.Create(
this.SearchAsync,
name: this._options.FunctionToolName ?? DefaultPluginSearchFunctionName,
description: this._options.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription)
name: options?.FunctionToolName ?? DefaultPluginSearchFunctionName,
description: options?.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription)
];
}
/// <inheritdoc />
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
if (this._options.SearchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke)
if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke)
{
// Expose the search tool for on-demand invocation.
return new AIContext { Tools = this._tools }; // No automatic message injection.
@@ -171,7 +161,7 @@ public sealed class TextSearchProvider : AIContextProvider
/// <inheritdoc />
public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
int limit = this._options.RecentMessageMemoryLimit;
int limit = this._recentMessageMemoryLimit;
if (limit <= 0)
{
return default; // Memory disabled.
@@ -220,9 +210,9 @@ public sealed class TextSearchProvider : AIContextProvider
{
// Only persist values that differ from defaults plus recent memory configuration & messages.
TextSearchProviderState state = new();
if (this._options.RecentMessageMemoryLimit > 0 && this._recentMessagesText.Count > 0)
if (this._recentMessageMemoryLimit > 0 && this._recentMessagesText.Count > 0)
{
state.RecentMessagesText = this._recentMessagesText.Take(this._options.RecentMessageMemoryLimit).ToList();
state.RecentMessagesText = this._recentMessagesText.Take(this._recentMessageMemoryLimit).ToList();
}
return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(TextSearchProviderState)));
@@ -253,9 +243,9 @@ public sealed class TextSearchProvider : AIContextProvider
/// <returns>Formatted string (may be empty).</returns>
private string FormatResults(IList<TextSearchResult> results)
{
if (this._options.ContextFormatter is not null)
if (this._contextFormatter is not null)
{
return this._options.ContextFormatter(results) ?? string.Empty;
return this._contextFormatter(results) ?? string.Empty;
}
if (results.Count == 0)
@@ -264,7 +254,7 @@ public sealed class TextSearchProvider : AIContextProvider
}
var sb = new StringBuilder();
sb.AppendLine(this._options.ContextPrompt ?? DefaultContextPrompt);
sb.AppendLine(this._contextPrompt);
for (int i = 0; i < results.Count; i++)
{
var result = results[i];
@@ -279,7 +269,7 @@ public sealed class TextSearchProvider : AIContextProvider
sb.AppendLine($"Contents: {result.Text}");
sb.AppendLine("----");
}
sb.AppendLine(this._options.CitationsPrompt ?? DefaultCitationsPrompt);
sb.AppendLine(this._citationsPrompt);
sb.AppendLine();
return sb.ToString();
}
@@ -32,7 +32,7 @@
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7"/>
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
@@ -13,7 +13,7 @@ using Azure.Core;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.AzureAI.UnitTests.Extensions;
namespace Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.Extensions;
public sealed class PersistentAgentsClientExtensionsTests
{
@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
</Project>
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
@@ -22,16 +23,19 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests;
/// </summary>
public abstract class ConformanceTestBase : IAsyncDisposable
{
protected const string TracesBasePath = "ConformanceTraces/Responses";
protected const string TracesBasePath = "ConformanceTraces";
protected const string ResponsesTracesDirectory = "Responses";
protected const string ChatCompletionsTracesDirectory = "ChatCompletions";
private WebApplication? _app;
private HttpClient? _httpClient;
/// <summary>
/// Loads a JSON file from the conformance traces directory.
/// </summary>
protected static string LoadTraceFile(string relativePath)
protected static string LoadTraceFile(string directory, string relativePath)
{
var fullPath = Path.Combine(TracesBasePath, relativePath);
var fullPath = Path.Combine(TracesBasePath, directory, relativePath);
if (!File.Exists(fullPath))
{
@@ -41,12 +45,33 @@ public abstract class ConformanceTestBase : IAsyncDisposable
return File.ReadAllText(fullPath);
}
/// <summary>
/// Loads a JSON file from the conformance traces directory.
/// </summary>
protected static string LoadResponsesTraceFile(string relativePath)
=> LoadTraceFile(ResponsesTracesDirectory, relativePath);
/// <summary>
/// Loads a JSON document from the conformance traces directory.
/// </summary>
protected static JsonDocument LoadTraceDocument(string relativePath)
protected static JsonDocument LoadResponsesTraceDocument(string relativePath)
{
var json = LoadTraceFile(relativePath);
var json = LoadResponsesTraceFile(relativePath);
return JsonDocument.Parse(json);
}
/// <summary>
/// Loads a JSON file from the conformance traces directory.
/// </summary>
protected static string LoadChatCompletionsTraceFile(string relativePath)
=> LoadTraceFile(ChatCompletionsTracesDirectory, relativePath);
/// <summary>
/// Loads a JSON document from the conformance traces directory.
/// </summary>
protected static JsonDocument LoadChatCompletionsTraceDocument(string relativePath)
{
var json = LoadChatCompletionsTraceFile(relativePath);
return JsonDocument.Parse(json);
}
@@ -61,6 +86,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable
}
}
/// <summary>
/// Asserts that a JSON element has any of the passed string values.
/// </summary>
protected static void AssertJsonPropertyEquals(JsonElement element, string propertyName, params string[] anyOfValues)
{
AssertJsonPropertyExists(element, propertyName);
var actualValue = element.GetProperty(propertyName).GetString();
if (!anyOfValues.Contains(actualValue))
{
throw new Xunit.Sdk.XunitException($"Property '{propertyName}': expected any of '{string.Join("; ", anyOfValues)}', got '{actualValue}'");
}
}
/// <summary>
/// Asserts that a JSON element has a specific string value.
/// </summary>
@@ -75,6 +114,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable
}
}
/// <summary>
/// Asserts that a JSON element has a specific string value.
/// </summary>
protected static void AssertJsonPropertyEquals(JsonElement element, string propertyName, float expectedValue)
{
AssertJsonPropertyExists(element, propertyName);
var actualValue = element.GetProperty(propertyName).GetDouble();
if (actualValue != expectedValue)
{
throw new Xunit.Sdk.XunitException($"Property '{propertyName}': expected '{expectedValue}', got '{actualValue}'");
}
}
/// <summary>
/// Asserts that a JSON element has a specific integer value.
/// </summary>
@@ -141,10 +194,12 @@ public abstract class ConformanceTestBase : IAsyncDisposable
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client");
builder.AddOpenAIResponses();
builder.AddOpenAIChatCompletions();
this._app = builder.Build();
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
this._app.MapOpenAIResponses(agent);
this._app.MapOpenAIChatCompletions(agent);
await this._app.StartAsync();
@@ -171,10 +226,12 @@ public abstract class ConformanceTestBase : IAsyncDisposable
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client");
builder.AddOpenAIResponses();
builder.AddOpenAIChatCompletions();
this._app = builder.Build();
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
this._app.MapOpenAIResponses(agent);
this._app.MapOpenAIChatCompletions(agent);
await this._app.StartAsync();
@@ -188,12 +245,21 @@ public abstract class ConformanceTestBase : IAsyncDisposable
/// <summary>
/// Sends a POST request with JSON content to the test server.
/// </summary>
protected async Task<HttpResponseMessage> SendRequestAsync(HttpClient client, string agentName, string requestJson)
protected async Task<HttpResponseMessage> SendResponsesRequestAsync(HttpClient client, string agentName, string requestJson)
{
StringContent content = new(requestJson, Encoding.UTF8, "application/json");
return await client.PostAsync(new Uri($"/{agentName}/v1/responses", UriKind.Relative), content);
}
/// <summary>
/// Sends a POST request with JSON content to the test server.
/// </summary>
protected async Task<HttpResponseMessage> SendChatCompletionRequestAsync(HttpClient client, string agentName, string requestJson)
{
StringContent content = new(requestJson, Encoding.UTF8, "application/json");
return await client.PostAsync(new Uri($"/{agentName}/v1/chat/completions", UriKind.Relative), content);
}
/// <summary>
/// Parses the response JSON and returns a JsonDocument.
/// </summary>
@@ -0,0 +1,12 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "Hello, how are you?"
}
],
"max_completion_tokens": 100,
"temperature": 1.0,
"top_p": 1.0
}
@@ -0,0 +1,33 @@
{
"id": "chatcmpl-AaBbCcDdEeFfGg",
"object": "chat.completion",
"created": 1730371200,
"model": "gpt-4o-mini-2024-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! I'm doing well, thank you. How about you?"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 13,
"completion_tokens": 14,
"total_tokens": 27,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": "fp_1234567890"
}
@@ -0,0 +1,34 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "What's the weather in San Francisco?"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA"
},
"unit": {
"type": "string",
"enum": [ "celsius", "fahrenheit" ],
"description": "The unit of temperature"
}
},
"required": [ "location" ]
}
}
}
],
"tool_choice": "auto"
}
@@ -0,0 +1,43 @@
{
"id": "chatcmpl-DEF456",
"object": "chat.completion",
"created": 1730371250,
"model": "gpt-4o-mini-2024-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123xyz",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"San Francisco, CA\",\"unit\":\"fahrenheit\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 85,
"completion_tokens": 18,
"total_tokens": 103,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": "fp_1234567890"
}
@@ -0,0 +1,36 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant that outputs JSON."
},
{
"role": "user",
"content": "Provide information about a person named John Doe, age 30, who is a software engineer."
}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "person_info",
"strict": true,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "number"
},
"occupation": {
"type": "string"
}
},
"required": [ "name", "age", "occupation" ],
"additionalProperties": false
}
}
}
}
@@ -0,0 +1,33 @@
{
"id": "chatcmpl-MNO345",
"object": "chat.completion",
"created": 1730371400,
"model": "gpt-4o-mini-2024-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\"name\":\"John Doe\",\"age\":30,\"occupation\":\"software engineer\"}"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 45,
"completion_tokens": 18,
"total_tokens": 63,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": "fp_5544332211"
}
@@ -0,0 +1,18 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "What is 2+2?"
},
{
"role": "assistant",
"content": "2+2 equals 4."
},
{
"role": "user",
"content": "What about 3+3?"
}
],
"max_completion_tokens": 50
}
@@ -0,0 +1,33 @@
{
"id": "chatcmpl-JKL012",
"object": "chat.completion",
"created": 1730371350,
"model": "gpt-4o-mini-2024-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "3+3 equals 6."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 35,
"completion_tokens": 8,
"total_tokens": 43,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": "fp_1122334455"
}
@@ -0,0 +1,12 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "Write a short poem about AI."
}
],
"max_completion_tokens": 150,
"temperature": 1.0,
"stream": true
}
@@ -0,0 +1,21 @@
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":"In"},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" circuits"},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" bright"},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":","},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" minds"},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" take"},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" flight"},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":"."},"finish_reason":null}]}
data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":12,"total_tokens":24,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}}
data: [DONE]
@@ -0,0 +1,14 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant that speaks like a pirate."
},
{
"role": "user",
"content": "Tell me about the ocean."
}
],
"max_completion_tokens": 100
}
@@ -0,0 +1,33 @@
{
"id": "chatcmpl-GHI789",
"object": "chat.completion",
"created": 1730371300,
"model": "gpt-4o-mini-2024-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Ahoy, matey! The ocean be a vast, mysterious realm full of treasures and creatures!"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 28,
"completion_tokens": 20,
"total_tokens": 48,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": "fp_9876543210"
}
@@ -36,7 +36,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
]);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -76,7 +76,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
]);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -111,7 +111,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
]);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -144,7 +144,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, ErrorMessage);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -179,7 +179,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, ErrorMessage);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -213,7 +213,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, "Error message");
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -240,7 +240,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateImageContentAgentAsync(AgentName, ImageUrl, isDataUri: false);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -264,7 +264,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateImageContentAgentAsync(AgentName, DataUri, isDataUri: true);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -289,7 +289,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateImageContentWithDetailAgentAsync(AgentName, ImageUrl, Detail);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -313,7 +313,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateImageContentAgentAsync(AgentName, "https://example.com/test.png", isDataUri: false);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -339,7 +339,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, "audio/mpeg");
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -364,7 +364,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, "audio/wav");
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -390,7 +390,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, mediaType);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -415,7 +415,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateHostedFileContentAgentAsync(AgentName, FileId);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -438,7 +438,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateHostedFileContentAgentAsync(AgentName, "file-xyz789");
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -465,7 +465,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateFileContentAgentAsync(AgentName, FileDataUri, Filename);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -490,7 +490,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateFileContentAgentAsync(AgentName, FileDataUri, null);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -516,7 +516,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateMixedContentAgentAsync(AgentName);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -535,7 +535,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase
HttpClient client = await this.CreateErrorAndTextContentAgentAsync(AgentName);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
@@ -27,4 +27,16 @@
</None>
</ItemGroup>
<ItemGroup>
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\streaming\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\response.json" />
</ItemGroup>
</Project>
@@ -0,0 +1,495 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Tests;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
/// <summary>
/// Conformance tests for OpenAI Chat Completions API implementation behavior.
/// Tests use real API traces to ensure our implementation produces responses
/// that match OpenAI's wire format when processing actual requests through the server.
/// </summary>
public sealed class OpenAIChatCompletionsConformanceTests : ConformanceTestBase
{
[Fact]
public async Task BasicRequestResponseAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("basic/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("basic/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get the expected response text from the trace to use as mock response
string expectedText = expectedResponse.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content").GetString()!;
HttpClient client = await this.CreateTestServerAsync("basic-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "basic-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request to verify it was sent correctly
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Verify request was properly formatted (structure check)
AssertJsonPropertyEquals(request, "model", "gpt-4o-mini");
AssertJsonPropertyExists(request, "messages");
AssertJsonPropertyEquals(request, "max_completion_tokens", 100);
AssertJsonPropertyEquals(request, "temperature", 1.0f);
AssertJsonPropertyEquals(request, "top_p", 1.0f);
var messages = request.GetProperty("messages");
Assert.Equal(JsonValueKind.Array, messages.ValueKind);
Assert.True(messages.GetArrayLength() > 0, "Messages array should not be empty");
var firstMessage = messages[0];
AssertJsonPropertyEquals(firstMessage, "role", "user");
AssertJsonPropertyEquals(firstMessage, "content", "Hello, how are you?");
// Assert - Response metadata (IDs and timestamps are dynamic, just verify structure)
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "chat.completion");
AssertJsonPropertyExists(response, "created");
AssertJsonPropertyExists(response, "model");
var id = response.GetProperty("id").GetString();
Assert.NotNull(id);
Assert.StartsWith("chatcmpl-", id);
var createdAt = response.GetProperty("created").GetInt64();
Assert.True(createdAt > 0, "created should be a positive unix timestamp");
var model = response.GetProperty("model").GetString();
Assert.NotNull(model);
Assert.StartsWith("gpt-4o-mini", model);
// Assert - Choices array structure
AssertJsonPropertyExists(response, "choices");
var choices = response.GetProperty("choices");
Assert.Equal(JsonValueKind.Array, choices.ValueKind);
Assert.True(choices.GetArrayLength() > 0, "Choices array should not be empty");
// Assert - Choice structure
var firstChoice = choices[0];
AssertJsonPropertyExists(firstChoice, "index");
AssertJsonPropertyEquals(firstChoice, "index", 0);
AssertJsonPropertyExists(firstChoice, "message");
AssertJsonPropertyExists(firstChoice, "finish_reason");
var finishReason = firstChoice.GetProperty("finish_reason").GetString();
Assert.NotNull(finishReason);
Assert.Contains(finishReason, collection: ["stop", "length", "content_filter", "tool_calls"]);
// Assert - Message structure
var message = firstChoice.GetProperty("message");
AssertJsonPropertyExists(message, "role");
AssertJsonPropertyEquals(message, "role", "assistant");
AssertJsonPropertyExists(message, "content");
var content = message.GetProperty("content").GetString();
Assert.NotNull(content);
Assert.Equal(expectedText, content); // Verify actual content matches expected
// Assert - Usage statistics
AssertJsonPropertyExists(response, "usage");
var usage = response.GetProperty("usage");
AssertJsonPropertyExists(usage, "prompt_tokens");
AssertJsonPropertyExists(usage, "completion_tokens");
AssertJsonPropertyExists(usage, "total_tokens");
var promptTokens = usage.GetProperty("prompt_tokens").GetInt32();
var completionTokens = usage.GetProperty("completion_tokens").GetInt32();
var totalTokens = usage.GetProperty("total_tokens").GetInt32();
Assert.True(promptTokens > 0, "prompt_tokens should be positive");
Assert.True(completionTokens > 0, "completion_tokens should be positive");
Assert.Equal(promptTokens + completionTokens, totalTokens);
// Assert - Usage details
AssertJsonPropertyExists(usage, "prompt_tokens_details");
var promptDetails = usage.GetProperty("prompt_tokens_details");
AssertJsonPropertyExists(promptDetails, "cached_tokens");
AssertJsonPropertyExists(promptDetails, "audio_tokens");
Assert.True(promptDetails.GetProperty("cached_tokens").GetInt32() >= 0);
Assert.True(promptDetails.GetProperty("audio_tokens").GetInt32() >= 0);
AssertJsonPropertyExists(usage, "completion_tokens_details");
var completionDetails = usage.GetProperty("completion_tokens_details");
AssertJsonPropertyExists(completionDetails, "reasoning_tokens");
AssertJsonPropertyExists(completionDetails, "audio_tokens");
AssertJsonPropertyExists(completionDetails, "accepted_prediction_tokens");
AssertJsonPropertyExists(completionDetails, "rejected_prediction_tokens");
Assert.True(completionDetails.GetProperty("reasoning_tokens").GetInt32() >= 0);
Assert.True(completionDetails.GetProperty("audio_tokens").GetInt32() >= 0);
Assert.True(completionDetails.GetProperty("accepted_prediction_tokens").GetInt32() >= 0);
Assert.True(completionDetails.GetProperty("rejected_prediction_tokens").GetInt32() >= 0);
// Assert - Optional fields
AssertJsonPropertyExists(response, "service_tier");
var serviceTier = response.GetProperty("service_tier").GetString();
Assert.NotNull(serviceTier);
Assert.True(serviceTier == "default" || serviceTier == "auto", $"service_tier should be 'default' or 'auto', got '{serviceTier}'");
}
[Fact]
public async Task StreamingRequestResponseAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("streaming/request.json");
string expectedResponseSse = LoadChatCompletionsTraceFile("streaming/response.txt");
// Extract expected text from SSE chunks
var expectedChunks = ParseChatCompletionChunksFromSse(expectedResponseSse);
string expectedText = string.Concat(expectedChunks
.Where(c => c.GetProperty("choices")[0].GetProperty("delta").TryGetProperty("content", out var content))
.Select(c => c.GetProperty("choices")[0].GetProperty("delta").GetProperty("content").GetString()));
HttpClient client = await this.CreateTestServerAsync("streaming-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "streaming-agent", requestJson);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType);
string responseSse = await httpResponse.Content.ReadAsStringAsync();
var chunks = ParseChatCompletionChunksFromSse(responseSse);
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has stream flag
AssertJsonPropertyEquals(request, "stream", true);
// Assert - Response has valid chunks
Assert.NotEmpty(chunks);
// Assert - All chunks have same ID
string? firstId = null;
foreach (var chunk in chunks)
{
AssertJsonPropertyExists(chunk, "id");
AssertJsonPropertyEquals(chunk, "object", "chat.completion.chunk");
AssertJsonPropertyExists(chunk, "created");
AssertJsonPropertyExists(chunk, "model");
AssertJsonPropertyExists(chunk, "choices");
string chunkId = chunk.GetProperty("id").GetString()!;
Assert.StartsWith("chatcmpl-", chunkId);
firstId ??= chunkId;
Assert.Equal(firstId, chunkId);
}
// Assert - First chunk has role
var firstChunk = chunks[0];
var firstChoice = firstChunk.GetProperty("choices")[0];
AssertJsonPropertyExists(firstChoice, "delta");
var firstDelta = firstChoice.GetProperty("delta");
if (firstDelta.TryGetProperty("role", out var role))
{
Assert.Equal("assistant", role.GetString());
}
// Assert - Content chunks have delta content
var contentChunks = chunks.Where(c =>
c.GetProperty("choices")[0].GetProperty("delta").TryGetProperty("content", out _)).ToList();
Assert.NotEmpty(contentChunks);
// Assert - Last chunk has finish_reason
var lastChunk = chunks[^1];
var lastChoice = lastChunk.GetProperty("choices")[0];
if (lastChoice.TryGetProperty("finish_reason", out var finishReason) && finishReason.ValueKind != JsonValueKind.Null)
{
string reason = finishReason.GetString()!;
Assert.Contains(reason, collection: ["stop", "length", "tool_calls", "content_filter"]);
}
// Assert - Last chunk may have usage
if (lastChunk.TryGetProperty("usage", out var usage))
{
AssertJsonPropertyExists(usage, "prompt_tokens");
AssertJsonPropertyExists(usage, "completion_tokens");
AssertJsonPropertyExists(usage, "total_tokens");
}
// Assert - Accumulated content matches expected
string accumulatedText = string.Concat(contentChunks
.Select(c => c.GetProperty("choices")[0].GetProperty("delta").GetProperty("content").GetString()));
Assert.NotEmpty(accumulatedText);
}
[Fact]
public async Task FunctionCallingRequestResponseAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("function_calling/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("function_calling/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get expected function call details
const string FunctionName = "get_weather";
HttpClient client = await this.CreateTestServerAsync("function-agent", "You are a helpful assistant.", FunctionName,
(msg) => [new FunctionCallContent("call_abc123xyz", "get_weather", new Dictionary<string, object?>() {
{ "location", "San Francisco, CA" },
{ "unit", "fahrenheit" }
})]
);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "function-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has tools array
AssertJsonPropertyExists(request, "tools");
var tools = request.GetProperty("tools");
Assert.Equal(JsonValueKind.Array, tools.ValueKind);
Assert.True(tools.GetArrayLength() > 0);
// Assert - Tool structure
var tool = tools[0];
AssertJsonPropertyEquals(tool, "type", "function");
AssertJsonPropertyExists(tool, "function");
var function = tool.GetProperty("function");
AssertJsonPropertyEquals(function, "name", "get_weather");
AssertJsonPropertyExists(function, "description");
AssertJsonPropertyExists(function, "parameters");
// Assert - Parameters have JSON Schema
var parameters = function.GetProperty("parameters");
AssertJsonPropertyEquals(parameters, "type", "object");
AssertJsonPropertyExists(parameters, "properties");
AssertJsonPropertyExists(parameters, "required");
// Assert - Response has tool_calls. Not always will return that, so can default to "stop"
var choices = response.GetProperty("choices");
var choice = choices[0];
var message = choice.GetProperty("message");
AssertJsonPropertyEquals(choice, "finish_reason", ["tool_calls", "stop"]);
AssertJsonPropertyExists(message, "tool_calls");
// Assert - Tool call structure
var toolCalls = message.GetProperty("tool_calls");
Assert.Equal(JsonValueKind.Array, toolCalls.ValueKind);
Assert.True(toolCalls.GetArrayLength() > 0);
var toolCall = toolCalls[0];
AssertJsonPropertyExists(toolCall, "id");
AssertJsonPropertyEquals(toolCall, "type", "function");
AssertJsonPropertyExists(toolCall, "function");
var callFunction = toolCall.GetProperty("function");
AssertJsonPropertyEquals(callFunction, "name", "get_weather");
AssertJsonPropertyExists(callFunction, "arguments");
// Assert - Arguments are valid JSON
string arguments = callFunction.GetProperty("arguments").GetString()!;
using var argsDoc = JsonDocument.Parse(arguments);
var argsRoot = argsDoc.RootElement;
AssertJsonPropertyExists(argsRoot, "location");
// Assert - Message content is null when tool_calls present. Can be absent or null.
if (message.TryGetProperty("content", out var contentProp))
{
Assert.Equal(JsonValueKind.Null, contentProp.ValueKind);
}
}
[Fact]
public async Task SystemMessageRequestResponseAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("system_message/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("system_message/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
string expectedText = expectedResponse.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content").GetString()!;
HttpClient client = await this.CreateTestServerAsync("system-agent", "You are a helpful assistant that speaks like a pirate.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "system-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has messages with system role
var messages = request.GetProperty("messages");
Assert.True(messages.GetArrayLength() >= 2);
var systemMessage = messages[0];
AssertJsonPropertyEquals(systemMessage, "role", "system");
AssertJsonPropertyExists(systemMessage, "content");
string systemContent = systemMessage.GetProperty("content").GetString()!;
Assert.Contains("pirate", systemContent, System.StringComparison.OrdinalIgnoreCase);
var userMessage = messages[1];
AssertJsonPropertyEquals(userMessage, "role", "user");
// Assert - Response reflects system message influence
var responseMessage = response.GetProperty("choices")[0].GetProperty("message");
string content = responseMessage.GetProperty("content").GetString()!;
Assert.NotNull(content);
Assert.Equal(expectedText, content);
}
[Fact]
public async Task MultiTurnConversationRequestResponseAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("multi_turn/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("multi_turn/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
string expectedText = expectedResponse.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content").GetString()!;
HttpClient client = await this.CreateTestServerAsync("multi-turn-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "multi-turn-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has conversation history
var messages = request.GetProperty("messages");
Assert.True(messages.GetArrayLength() >= 3, "Should have at least 3 messages for multi-turn");
// Assert - Message sequence alternates between user and assistant
AssertJsonPropertyEquals(messages[0], "role", "user");
AssertJsonPropertyEquals(messages[1], "role", "assistant");
AssertJsonPropertyEquals(messages[2], "role", "user");
// Assert - Response continues conversation
var responseMessage = response.GetProperty("choices")[0].GetProperty("message");
AssertJsonPropertyEquals(responseMessage, "role", "assistant");
string content = responseMessage.GetProperty("content").GetString()!;
Assert.NotNull(content);
Assert.Equal(expectedText, content);
// Assert - Usage tokens account for conversation history
var usage = response.GetProperty("usage");
int promptTokens = usage.GetProperty("prompt_tokens").GetInt32();
Assert.True(promptTokens > 20, "Prompt tokens should account for conversation history");
}
[Fact]
public async Task JsonModeRequestResponseAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("json_mode/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("json_mode/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
string expectedText = expectedResponse.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content").GetString()!;
HttpClient client = await this.CreateTestServerAsync("json-agent", "You are a helpful assistant that outputs JSON.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "json-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has response_format with json_schema
AssertJsonPropertyExists(request, "response_format");
var responseFormat = request.GetProperty("response_format");
AssertJsonPropertyEquals(responseFormat, "type", "json_schema");
AssertJsonPropertyExists(responseFormat, "json_schema");
var jsonSchema = responseFormat.GetProperty("json_schema");
AssertJsonPropertyEquals(jsonSchema, "name", "person_info");
AssertJsonPropertyEquals(jsonSchema, "strict", true);
AssertJsonPropertyExists(jsonSchema, "schema");
var schema = jsonSchema.GetProperty("schema");
AssertJsonPropertyEquals(schema, "type", "object");
AssertJsonPropertyExists(schema, "properties");
AssertJsonPropertyExists(schema, "required");
// Assert - Response content is valid JSON matching schema
var responseMessage = response.GetProperty("choices")[0].GetProperty("message");
string content = responseMessage.GetProperty("content").GetString()!;
Assert.NotNull(content);
Assert.Equal(expectedText, content);
using var jsonDoc = JsonDocument.Parse(content);
var jsonRoot = jsonDoc.RootElement;
AssertJsonPropertyExists(jsonRoot, "name");
AssertJsonPropertyExists(jsonRoot, "age");
AssertJsonPropertyExists(jsonRoot, "occupation");
Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("name").ValueKind);
Assert.Equal(JsonValueKind.Number, jsonRoot.GetProperty("age").ValueKind);
Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("occupation").ValueKind);
}
/// <summary>
/// Helper to parse chat completion chunks from SSE response.
/// </summary>
private static List<JsonElement> ParseChatCompletionChunksFromSse(string sseContent)
{
var chunks = new List<JsonElement>();
var lines = sseContent.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].TrimEnd('\r');
if (line.StartsWith("data: ", System.StringComparison.Ordinal))
{
var jsonData = line.Substring("data: ".Length);
// Skip [DONE] marker
if (jsonData == "[DONE]")
{
continue;
}
try
{
var doc = JsonDocument.Parse(jsonData);
chunks.Add(doc.RootElement.Clone());
}
catch
{
// Skip invalid JSON
}
}
}
return chunks;
}
}
@@ -0,0 +1,974 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenAI;
using OpenAI.Chat;
using ChatFinishReason = OpenAI.Chat.ChatFinishReason;
using ChatMessage = OpenAI.Chat.ChatMessage;
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
/// <summary>
/// Integration tests that start a web server and use the OpenAI Chat Completions SDK client to verify protocol compatibility.
/// These tests validate both streaming and non-streaming request scenarios.
/// </summary>
public sealed class OpenAIChatCompletionsIntegrationTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _httpClient;
public async ValueTask DisposeAsync()
{
this._httpClient?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
/// <summary>
/// Verifies that streaming chat completions work correctly with the OpenAI SDK client.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_WithSimpleMessage_ReturnsStreamingUpdatesAsync()
{
// Arrange
const string AgentName = "streaming-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "One Two Three";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Count to 3")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> updates = [];
StringBuilder contentBuilder = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
updates.Add(update);
if (update.ContentUpdate.Count > 0)
{
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
contentBuilder.Append(contentPart.Text);
}
}
}
Assert.NotEmpty(updates);
// Verify content was received
string content = contentBuilder.ToString();
Assert.Equal(ExpectedResponse, content);
// Verify finish reason
StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null);
Assert.NotNull(lastUpdate);
Assert.Equal(ChatFinishReason.Stop, lastUpdate.FinishReason);
}
/// <summary>
/// Verifies that non-streaming chat completions work correctly with the OpenAI SDK client.
/// </summary>
[Fact]
public async Task CreateChatCompletion_WithSimpleMessage_ReturnsCompleteResponseAsync()
{
// Arrange
const string AgentName = "non-streaming-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Hello! How can I help you today?";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Hello")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
Assert.NotNull(completion);
Assert.NotNull(completion.Id);
Assert.StartsWith("chatcmpl-", completion.Id);
Assert.Equal(ChatFinishReason.Stop, completion.FinishReason);
// Verify content
string content = completion.Content[0].Text;
Assert.Equal(ExpectedResponse, content);
}
/// <summary>
/// Verifies that streaming chat completions can handle multiple content chunks.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_WithMultipleChunks_StreamsAllContentAsync()
{
// Arrange
const string AgentName = "multi-chunk-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "This is a test response with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> updates = [];
StringBuilder contentBuilder = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
updates.Add(update);
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
contentBuilder.Append(contentPart.Text);
}
}
// Verify all content was received
string receivedContent = contentBuilder.ToString();
Assert.Equal(ExpectedResponse, receivedContent);
// Verify multiple content chunks were received
List<StreamingChatCompletionUpdate> contentUpdates = updates.Where(u => u.ContentUpdate.Count > 0).ToList();
Assert.True(contentUpdates.Count > 1, "Expected multiple content chunks in streaming response");
}
/// <summary>
/// Verifies that multiple agents can be accessed via the same server.
/// </summary>
[Fact]
public async Task CreateChatCompletion_WithMultipleAgents_EachAgentRespondsCorrectlyAsync()
{
// Arrange
const string Agent1Name = "agent-one";
const string Agent1Instructions = "You are agent one.";
const string Agent1Response = "Response from agent one";
const string Agent2Name = "agent-two";
const string Agent2Instructions = "You are agent two.";
const string Agent2Response = "Response from agent two";
this._httpClient = await this.CreateTestServerWithMultipleAgentsAsync(
(Agent1Name, Agent1Instructions, Agent1Response),
(Agent2Name, Agent2Instructions, Agent2Response));
ChatClient chatClient1 = this.CreateChatClient(Agent1Name);
ChatClient chatClient2 = this.CreateChatClient(Agent2Name);
List<ChatMessage> messages =
[
new UserChatMessage("Hello")
];
// Act
ChatCompletion completion1 = await chatClient1.CompleteChatAsync(messages);
ChatCompletion completion2 = await chatClient2.CompleteChatAsync(messages);
// Assert
string content1 = completion1.Content[0].Text;
string content2 = completion2.Content[0].Text;
Assert.Equal(Agent1Response, content1);
Assert.Equal(Agent2Response, content2);
Assert.NotEqual(content1, content2);
}
/// <summary>
/// Verifies that streaming and non-streaming work correctly for the same agent.
/// </summary>
[Fact]
public async Task CreateChatCompletion_SameAgentStreamingAndNonStreaming_BothWorkCorrectlyAsync()
{
// Arrange
const string AgentName = "dual-mode-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "This is the response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act - Non-streaming
ChatCompletion nonStreamingCompletion = await chatClient.CompleteChatAsync(messages);
// Act - Streaming
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
StringBuilder streamingContent = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
streamingContent.Append(contentPart.Text);
}
}
// Assert
string nonStreamingContent = nonStreamingCompletion.Content[0].Text;
Assert.Equal(ExpectedResponse, nonStreamingContent);
Assert.Equal(ExpectedResponse, streamingContent.ToString());
}
/// <summary>
/// Verifies that the finish reason is correctly set for completed responses.
/// </summary>
[Fact]
public async Task CreateChatCompletion_CompletedResponse_HasCorrectFinishReasonAsync()
{
// Arrange
const string AgentName = "finish-reason-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Complete";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
Assert.Equal(ChatFinishReason.Stop, completion.FinishReason);
Assert.NotNull(completion.Id);
Assert.Equal(ExpectedResponse, completion.Content[0].Text);
}
/// <summary>
/// Verifies that streaming responses contain the expected chunk sequence.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_VerifyChunkSequence_ContainsExpectedDataAsync()
{
// Arrange
const string AgentName = "chunk-sequence-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Test response with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> updates = [];
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
updates.Add(update);
}
// Verify chunks received
Assert.NotEmpty(updates);
// First chunk should have role
StreamingChatCompletionUpdate? firstUpdate = updates.FirstOrDefault(u => u.Role != null);
if (firstUpdate != null)
{
Assert.Equal(ChatMessageRole.Assistant, firstUpdate.Role);
}
// Should contain content chunks
List<StreamingChatCompletionUpdate> contentUpdates = updates.Where(u => u.ContentUpdate.Count > 0).ToList();
Assert.NotEmpty(contentUpdates);
// Last update should have finish reason
StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null);
Assert.NotNull(lastUpdate);
Assert.Equal(ChatFinishReason.Stop, lastUpdate.FinishReason);
}
/// <summary>
/// Verifies that streaming responses properly handle empty responses.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_EmptyResponse_HandlesGracefullyAsync()
{
// Arrange
const string AgentName = "empty-response-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> updates = [];
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
updates.Add(update);
}
// Should still receive chunks with finish reason
Assert.NotEmpty(updates);
Assert.Contains(updates, u => u.FinishReason == ChatFinishReason.Stop);
}
/// <summary>
/// Verifies that non-streaming responses include proper metadata.
/// </summary>
[Fact]
public async Task CreateChatCompletion_IncludesMetadata_HasRequiredFieldsAsync()
{
// Arrange
const string AgentName = "metadata-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Response with metadata";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
Assert.NotNull(completion.Id);
Assert.StartsWith("chatcmpl-", completion.Id);
Assert.NotNull(completion.Model);
Assert.NotEqual(default, completion.CreatedAt);
Assert.Equal(ChatFinishReason.Stop, completion.FinishReason);
}
/// <summary>
/// Verifies that streaming responses handle very long text correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_LongText_StreamsAllContentAsync()
{
// Arrange
const string AgentName = "long-text-agent";
const string Instructions = "You are a helpful assistant.";
string expectedResponse = string.Join(" ", Enumerable.Range(1, 100).Select(i => $"Word{i}"));
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, expectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Generate long text")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
StringBuilder contentBuilder = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
contentBuilder.Append(contentPart.Text);
}
}
string receivedContent = contentBuilder.ToString();
Assert.Equal(expectedResponse, receivedContent);
}
/// <summary>
/// Verifies that streaming responses properly handle single-word responses.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_SingleWord_StreamsCorrectlyAsync()
{
// Arrange
const string AgentName = "single-word-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Hello";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
StringBuilder contentBuilder = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
contentBuilder.Append(contentPart.Text);
}
}
Assert.Equal(ExpectedResponse, contentBuilder.ToString());
}
/// <summary>
/// Verifies that streaming responses preserve special characters and formatting.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_SpecialCharacters_PreservesFormattingAsync()
{
// Arrange
const string AgentName = "special-chars-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Hello! How are you? I'm fine. 100% great!";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
StringBuilder contentBuilder = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
contentBuilder.Append(contentPart.Text);
}
}
Assert.Equal(ExpectedResponse, contentBuilder.ToString());
}
/// <summary>
/// Verifies that non-streaming responses handle special characters correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletion_SpecialCharacters_PreservesContentAsync()
{
// Arrange
const string AgentName = "special-chars-nonstreaming-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Symbols: @#$%^&*() Quotes: \"Hello\" 'World' Unicode: 你好 🌍";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
string content = completion.Content[0].Text;
Assert.Equal(ExpectedResponse, content);
}
/// <summary>
/// Verifies that multiple sequential non-streaming requests work correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletion_MultipleSequentialRequests_AllSucceedAsync()
{
// Arrange
const string AgentName = "sequential-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
// Act & Assert - Make 5 sequential requests
for (int i = 0; i < 5; i++)
{
List<ChatMessage> messages =
[
new UserChatMessage($"Request {i}")
];
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
Assert.NotNull(completion);
Assert.Equal(ChatFinishReason.Stop, completion.FinishReason);
Assert.Equal(ExpectedResponse, completion.Content[0].Text);
}
}
/// <summary>
/// Verifies that multiple sequential streaming requests work correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_MultipleSequentialRequests_AllStreamCorrectlyAsync()
{
// Arrange
const string AgentName = "sequential-streaming-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Streaming response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
// Act & Assert - Make 3 sequential streaming requests
for (int i = 0; i < 3; i++)
{
List<ChatMessage> messages =
[
new UserChatMessage($"Request {i}")
];
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
StringBuilder contentBuilder = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
contentBuilder.Append(contentPart.Text);
}
}
Assert.Equal(ExpectedResponse, contentBuilder.ToString());
}
}
/// <summary>
/// Verifies that completion IDs are unique across multiple requests.
/// </summary>
[Fact]
public async Task CreateChatCompletion_MultipleRequests_GenerateUniqueIdsAsync()
{
// Arrange
const string AgentName = "unique-id-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
// Act
List<string> completionIds = [];
for (int i = 0; i < 10; i++)
{
List<ChatMessage> messages =
[
new UserChatMessage($"Request {i}")
];
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
completionIds.Add(completion.Id);
}
// Assert
Assert.Equal(10, completionIds.Count);
Assert.Equal(completionIds.Count, completionIds.Distinct().Count()); // All IDs should be unique
}
/// <summary>
/// Verifies that streaming responses all have the same ID within a single request.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_SameRequestId_ConsistentAcrossChunksAsync()
{
// Arrange
const string AgentName = "consistent-id-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Test consistent ID across chunks";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<string> chunkIds = [];
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
if (!string.IsNullOrEmpty(update.CompletionId))
{
chunkIds.Add(update.CompletionId);
}
}
// All chunk IDs should be the same within a single request
Assert.NotEmpty(chunkIds);
Assert.All(chunkIds, id => Assert.Equal(chunkIds[0], id));
Assert.StartsWith("chatcmpl-", chunkIds[0]);
}
/// <summary>
/// Verifies that non-streaming responses work with system messages.
/// </summary>
[Fact]
public async Task CreateChatCompletion_WithSystemMessage_ReturnsValidResponseAsync()
{
// Arrange
const string AgentName = "system-message-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "I am following the system instructions";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new SystemChatMessage("You must respond in a specific way"),
new UserChatMessage("Hello")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
Assert.NotNull(completion);
Assert.Equal(ChatFinishReason.Stop, completion.FinishReason);
Assert.Equal(ExpectedResponse, completion.Content[0].Text);
}
/// <summary>
/// Verifies that responses handle newlines correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletion_Newlines_PreservesFormattingAsync()
{
// Arrange
const string AgentName = "newline-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Line 1\nLine 2\nLine 3";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
string content = completion.Content[0].Text;
Assert.Equal(ExpectedResponse, content);
Assert.Contains("\n", content);
}
/// <summary>
/// Verifies that streaming responses handle newlines correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_Newlines_PreservesFormattingAsync()
{
// Arrange
const string AgentName = "newline-streaming-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "First line\nSecond line\nThird line";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
StringBuilder contentBuilder = new();
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
foreach (ChatMessageContentPart contentPart in update.ContentUpdate)
{
contentBuilder.Append(contentPart.Text);
}
}
string content = contentBuilder.ToString();
Assert.Equal(ExpectedResponse, content);
Assert.Contains("\n", content);
}
/// <summary>
/// Verifies that responses with conversation history work correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletion_WithConversationHistory_ReturnsValidResponseAsync()
{
// Arrange
const string AgentName = "conversation-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "3 plus 3 equals 6";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("What is 2+2?"),
new AssistantChatMessage("2+2 equals 4"),
new UserChatMessage("What about 3+3?")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
Assert.NotNull(completion);
Assert.Equal(ChatFinishReason.Stop, completion.FinishReason);
Assert.Equal(ExpectedResponse, completion.Content[0].Text);
}
/// <summary>
/// Verifies that usage information is included in non-streaming responses.
/// </summary>
[Fact]
public async Task CreateChatCompletion_IncludesUsage_HasTokenCountsAsync()
{
// Arrange
const string AgentName = "usage-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Response with usage information";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
ChatClient chatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
Assert.NotNull(completion.Usage);
Assert.True(completion.Usage.InputTokenCount > 0);
Assert.True(completion.Usage.OutputTokenCount > 0);
Assert.Equal(completion.Usage.InputTokenCount + completion.Usage.OutputTokenCount, completion.Usage.TotalTokenCount);
}
/// <summary>
/// Verifies that responses with function calls work correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletion_WithFunctionCall_ReturnsToolCallsAsync()
{
// Arrange
const string AgentName = "function-call-agent";
const string Instructions = "You are a helpful assistant.";
const string FunctionName = "get_weather";
const string Arguments = "{\"location\":\"Seattle\"}";
this._httpClient = await this.CreateTestServerWithCustomClientAsync(
agentName: AgentName,
instructions: Instructions,
chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments));
ChatClient openAIChatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("What's the weather?")
];
// Act
ChatCompletion completion = await openAIChatClient.CompleteChatAsync(messages);
// Assert
Assert.NotNull(completion);
Assert.Equal(ChatFinishReason.ToolCalls, completion.FinishReason);
Assert.NotNull(completion.ToolCalls);
Assert.NotEmpty(completion.ToolCalls);
ChatToolCall toolCall = completion.ToolCalls[0];
Assert.Equal(FunctionName, toolCall.FunctionName);
Assert.NotNull(toolCall.FunctionArguments);
}
/// <summary>
/// Verifies that streaming responses with function calls work correctly.
/// </summary>
[Fact]
public async Task CreateChatCompletionStreaming_WithFunctionCall_StreamsToolCallsAsync()
{
// Arrange
const string AgentName = "function-call-streaming-agent";
const string Instructions = "You are a helpful assistant.";
const string FunctionName = "calculate";
const string Arguments = "{\"expression\":\"2+2\"}";
this._httpClient = await this.CreateTestServerWithCustomClientAsync(
agentName: AgentName,
instructions: Instructions,
chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments));
ChatClient openAIChatClient = this.CreateChatClient(AgentName);
List<ChatMessage> messages =
[
new UserChatMessage("Calculate 2+2")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = openAIChatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> updates = [];
await foreach (StreamingChatCompletionUpdate update in streamingResult)
{
updates.Add(update);
}
Assert.NotEmpty(updates);
// Should have finish reason of tool_calls
StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null);
Assert.NotNull(lastUpdate);
Assert.True(lastUpdate.FinishReason is ChatFinishReason.ToolCalls or ChatFinishReason.Stop); // depends on what response we get
}
private ChatClient CreateChatClient(string agentName)
{
return new ChatClient(
model: "test-model",
credential: new ApiKeyCredential("test-api-key"),
options: new OpenAIClientOptions
{
Endpoint = new Uri(this._httpClient!.BaseAddress!, $"/{agentName}/v1/"),
Transport = new HttpClientPipelineTransport(this._httpClient)
});
}
private async Task<HttpClient> CreateTestServerAsync(string agentName, string instructions, string responseText = "Test response")
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText);
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddOpenAIChatCompletions();
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client");
this._app = builder.Build();
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
this._app.MapOpenAIChatCompletions(agent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
return testServer.CreateClient();
}
private async Task<HttpClient> CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}");
builder.AddOpenAIChatCompletions();
this._app = builder.Build();
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
this._app.MapOpenAIChatCompletions(agent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
return testServer.CreateClient();
}
private async Task<HttpClient> CreateTestServerWithMultipleAgentsAsync(
params (string Name, string Instructions, string ResponseText)[] agents)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
foreach ((string name, string instructions, string responseText) in agents)
{
IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText);
builder.Services.AddKeyedSingleton($"chat-client-{name}", mockChatClient);
builder.AddAIAgent(name, instructions, chatClientServiceKey: $"chat-client-{name}");
}
builder.AddOpenAIChatCompletions();
this._app = builder.Build();
foreach ((string name, string _, string _) in agents)
{
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(name);
this._app.MapOpenAIChatCompletions(agent);
}
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
return testServer.CreateClient();
}
}
@@ -0,0 +1,576 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Agents.AI.Hosting.OpenAI.Tests;
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
/// <summary>
/// Tests for OpenAI ChatCompletions API model serialization and deserialization.
/// These tests verify that our models correctly serialize to and deserialize from JSON
/// matching the OpenAI wire format, without testing actual API implementation behavior.
/// </summary>
public sealed class OpenAIChatCompletionsSerializationTests : ConformanceTestBase
{
#region Request Deserialization Tests
[Fact]
public void Deserialize_BasicRequest_Success()
{
// Arrange
string json = LoadChatCompletionsTraceFile("basic/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.Equal("gpt-4o-mini", request.Model);
Assert.NotNull(request.Messages);
Assert.True(request.Messages.Count > 0);
Assert.Equal(100, request.MaxCompletionTokens);
}
[Fact]
public void Deserialize_BasicRequest_RoundTrip()
{
// Arrange
string originalJson = LoadChatCompletionsTraceFile("basic/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(originalJson, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
string reserializedJson = JsonSerializer.Serialize(request, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
CreateChatCompletion? roundtripped = JsonSerializer.Deserialize(reserializedJson, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.NotNull(roundtripped);
Assert.Equal(request.Model, roundtripped.Model);
Assert.Equal(request.MaxCompletionTokens, roundtripped.MaxCompletionTokens);
Assert.Equal(request.Messages.Count, roundtripped.Messages.Count);
}
[Fact]
public void Deserialize_BasicRequest_HasMessages()
{
// Arrange
string json = LoadChatCompletionsTraceFile("basic/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.NotNull(request.Messages);
Assert.Single(request.Messages);
var message = request.Messages[0];
Assert.Equal("user", message.Role);
Assert.NotNull(message.Content);
}
[Fact]
public void Deserialize_StreamingRequest_HasStreamFlag()
{
// Arrange
string json = LoadChatCompletionsTraceFile("streaming/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.True(request.Stream);
Assert.Equal(150, request.MaxCompletionTokens);
}
[Fact]
public void Deserialize_SystemMessageRequest_HasSystemRole()
{
// Arrange
string json = LoadChatCompletionsTraceFile("system_message/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.NotNull(request.Messages);
Assert.True(request.Messages.Count >= 2);
Assert.Equal("system", request.Messages[0].Role);
Assert.Equal("user", request.Messages[1].Role);
}
[Fact]
public void Deserialize_MultiTurnRequest_HasMultipleMessages()
{
// Arrange
string json = LoadChatCompletionsTraceFile("multi_turn/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.NotNull(request.Messages);
Assert.True(request.Messages.Count >= 3);
Assert.Equal("user", request.Messages[0].Role);
Assert.Equal("assistant", request.Messages[1].Role);
Assert.Equal("user", request.Messages[2].Role);
}
[Fact]
public void Deserialize_FunctionCallingRequest_HasTools()
{
// Arrange
string json = LoadChatCompletionsTraceFile("function_calling/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.NotNull(request.Tools);
Assert.Single(request.Tools);
Assert.NotNull(request.ToolChoice?.Mode);
Assert.Equal("auto", request.ToolChoice.Mode);
}
[Fact]
public void Deserialize_JsonModeRequest_HasResponseFormat()
{
// Arrange
string json = LoadChatCompletionsTraceFile("json_mode/request.json");
// Act
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
// Assert
Assert.NotNull(request);
Assert.NotNull(request.ResponseFormat);
}
[Fact]
public void Deserialize_AllRequests_CanBeDeserialized()
{
// Arrange
string[] requestPaths =
[
"basic/request.json",
"streaming/request.json",
"system_message/request.json",
"multi_turn/request.json",
"function_calling/request.json",
"json_mode/request.json"
];
foreach (var path in requestPaths)
{
string json = LoadChatCompletionsTraceFile(path);
// Act & Assert - Should not throw
CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion);
Assert.NotNull(request);
Assert.NotNull(request.Messages);
Assert.True(request.Messages.Count > 0, $"Request from {path} should have messages");
}
}
#endregion
#region Response Deserialization Tests
[Fact]
public void Deserialize_BasicResponse_Success()
{
// Arrange
string json = LoadChatCompletionsTraceFile("basic/response.json");
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.StartsWith("chatcmpl-", response.Id);
Assert.Equal("chat.completion", response.Object);
Assert.True(response.Created > 0);
Assert.NotNull(response.Model);
Assert.StartsWith("gpt-4o-mini", response.Model);
}
[Fact]
public void Deserialize_BasicResponse_HasChoices()
{
// Arrange
string json = LoadChatCompletionsTraceFile("basic/response.json");
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Choices);
Assert.Single(response.Choices);
var choice = response.Choices[0];
Assert.Equal(0, choice.Index);
Assert.NotNull(choice.Message);
Assert.Equal("assistant", choice.Message.Role);
Assert.NotNull(choice.Message.Content);
Assert.NotNull(choice.FinishReason);
}
[Fact]
public void Deserialize_BasicResponse_HasUsage()
{
// Arrange
string json = LoadChatCompletionsTraceFile("basic/response.json");
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Usage);
Assert.True(response.Usage.PromptTokens > 0);
Assert.True(response.Usage.CompletionTokens > 0);
Assert.Equal(response.Usage.PromptTokens + response.Usage.CompletionTokens, response.Usage.TotalTokens);
Assert.NotNull(response.Usage.PromptTokensDetails);
Assert.NotNull(response.Usage.CompletionTokensDetails);
}
[Fact]
public void Deserialize_SystemMessageResponse_HasContent()
{
// Arrange
string json = LoadChatCompletionsTraceFile("system_message/response.json");
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Choices);
var message = response.Choices[0].Message;
Assert.Equal("assistant", message.Role);
Assert.NotNull(message.Content);
Assert.Contains("Ahoy, matey", message.Content, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Deserialize_MultiTurnResponse_HasContent()
{
// Arrange
string json = LoadChatCompletionsTraceFile("multi_turn/response.json");
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Choices);
var message = response.Choices[0].Message;
Assert.Equal("assistant", message.Role);
Assert.NotNull(message.Content);
}
[Fact]
public void Deserialize_FunctionCallingResponse_HasToolCalls()
{
// Arrange
string json = LoadChatCompletionsTraceFile("function_calling/response.json");
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Choices);
var choice = response.Choices[0];
Assert.Equal("tool_calls", choice.FinishReason);
var message = choice.Message;
Assert.NotNull(message.ToolCalls);
Assert.Single(message.ToolCalls);
var toolCall = message.ToolCalls[0];
Assert.NotNull(toolCall.Id);
Assert.StartsWith("call_", toolCall.Id);
Assert.Equal("function", toolCall.Type);
Assert.NotNull(toolCall.Function);
Assert.Equal("get_weather", toolCall.Function.Name);
Assert.NotNull(toolCall.Function.Arguments);
}
[Fact]
public void Deserialize_JsonModeResponse_HasStructuredOutput()
{
// Arrange
string json = LoadChatCompletionsTraceFile("json_mode/response.json");
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Choices);
var message = response.Choices[0].Message;
Assert.NotNull(message.Content);
// Verify the content is valid JSON
using var jsonDoc = JsonDocument.Parse(message.Content);
var jsonRoot = jsonDoc.RootElement;
Assert.Equal(JsonValueKind.Object, jsonRoot.ValueKind);
Assert.True(jsonRoot.TryGetProperty("name", out _));
Assert.True(jsonRoot.TryGetProperty("age", out _));
Assert.True(jsonRoot.TryGetProperty("occupation", out _));
}
[Fact]
public void Deserialize_AllResponses_HaveRequiredFields()
{
// Arrange
string[] responsePaths =
[
"basic/response.json",
"system_message/response.json",
"multi_turn/response.json",
"function_calling/response.json",
"json_mode/response.json"
];
foreach (var path in responsePaths)
{
string json = LoadChatCompletionsTraceFile(path);
// Act
ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Id);
Assert.Equal("chat.completion", response.Object);
Assert.True(response.Created > 0, $"Response from {path} should have created timestamp");
Assert.NotNull(response.Model);
Assert.NotNull(response.Choices);
Assert.True(response.Choices.Count > 0, $"Response from {path} should have choices");
}
}
[Fact]
public void Deserialize_ResponseRoundTrip_PreservesData()
{
// Arrange
string originalJson = LoadChatCompletionsTraceFile("basic/response.json");
// Act - Deserialize and re-serialize
ChatCompletion? response = JsonSerializer.Deserialize(originalJson, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
string reserializedJson = JsonSerializer.Serialize(response, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
ChatCompletion? roundtripped = JsonSerializer.Deserialize(reserializedJson, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion);
// Assert
Assert.NotNull(response);
Assert.NotNull(roundtripped);
Assert.Equal(response.Id, roundtripped.Id);
Assert.Equal(response.Created, roundtripped.Created);
Assert.Equal(response.Model, roundtripped.Model);
Assert.Equal(response.Choices.Count, roundtripped.Choices.Count);
}
#endregion
#region Streaming Chunk Deserialization Tests
[Fact]
public void ParseStreamingChunks_BasicFormat_Success()
{
// Arrange
string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt");
// Act
var chunks = ParseChatCompletionChunksFromSse(sseContent);
// Assert
Assert.NotEmpty(chunks);
Assert.All(chunks, chunk =>
{
ChatCompletionChunk? parsed = JsonSerializer.Deserialize(chunk.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk);
Assert.NotNull(parsed);
Assert.NotNull(parsed.Id);
Assert.Equal("chat.completion.chunk", parsed.Object);
Assert.True(parsed.Created > 0);
Assert.NotNull(parsed.Model);
Assert.NotNull(parsed.Choices);
});
}
[Fact]
public void ParseStreamingChunks_AllChunksSameId()
{
// Arrange
string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt");
// Act
var chunks = ParseChatCompletionChunksFromSse(sseContent);
// Deserialize chunks
var parsedChunks = chunks
.Select(c => JsonSerializer.Deserialize(c.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk))
.Where(c => c != null)
.ToList();
// Assert
Assert.NotEmpty(parsedChunks);
string? firstId = parsedChunks[0]!.Id;
Assert.NotNull(firstId);
Assert.StartsWith("chatcmpl-", firstId);
Assert.All(parsedChunks, chunk => Assert.Equal(firstId, chunk!.Id));
}
[Fact]
public void ParseStreamingChunks_FirstChunkHasRole()
{
// Arrange
string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt");
// Act
var chunks = ParseChatCompletionChunksFromSse(sseContent);
var firstChunk = JsonSerializer.Deserialize(chunks[0].GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk);
// Assert
Assert.NotNull(firstChunk);
Assert.NotNull(firstChunk.Choices);
Assert.True(firstChunk.Choices.Count > 0);
var firstChoice = firstChunk.Choices[0];
Assert.NotNull(firstChoice.Delta);
if (firstChoice.Delta.Role != null)
{
Assert.Equal("assistant", firstChoice.Delta.Role);
}
}
[Fact]
public void ParseStreamingChunks_AccumulateContent_MatchesExpected()
{
// Arrange
string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt");
// Act
var chunks = ParseChatCompletionChunksFromSse(sseContent);
var contentPieces = new List<string>();
foreach (var chunkJson in chunks)
{
var chunk = JsonSerializer.Deserialize(chunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk);
if (chunk?.Choices != null && chunk.Choices.Count > 0)
{
var delta = chunk.Choices[0].Delta;
if (!string.IsNullOrEmpty(delta?.Content))
{
contentPieces.Add(delta.Content);
}
}
}
// Assert
Assert.NotEmpty(contentPieces);
string fullText = string.Concat(contentPieces);
Assert.NotEmpty(fullText);
Assert.Contains("circuits", fullText);
Assert.Contains("flight", fullText);
}
[Fact]
public void ParseStreamingChunks_LastChunkHasFinishReason()
{
// Arrange
string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt");
// Act
var chunks = ParseChatCompletionChunksFromSse(sseContent);
// Find chunks with finish_reason
var chunksWithFinishReason = new List<ChatCompletionChunk>();
foreach (var chunkJson in chunks)
{
var chunk = JsonSerializer.Deserialize(chunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk);
if (chunk?.Choices != null && chunk.Choices.Count > 0 && !string.IsNullOrEmpty(chunk.Choices[0].FinishReason))
{
chunksWithFinishReason.Add(chunk);
}
}
// Assert
Assert.NotEmpty(chunksWithFinishReason);
var lastChunk = chunksWithFinishReason.Last();
Assert.Contains(lastChunk.Choices[0].FinishReason, collection: ["stop", "length", "tool_calls", "content_filter"]);
}
[Fact]
public void ParseStreamingChunks_LastChunkHasUsage()
{
// Arrange
string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt");
// Act
var chunks = ParseChatCompletionChunksFromSse(sseContent);
var lastChunkJson = chunks.Last();
var lastChunk = JsonSerializer.Deserialize(lastChunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk);
// Assert
Assert.NotNull(lastChunk);
Assert.NotNull(lastChunk.Usage);
Assert.True(lastChunk.Usage.PromptTokens > 0);
Assert.True(lastChunk.Usage.CompletionTokens > 0);
Assert.Equal(lastChunk.Usage.PromptTokens + lastChunk.Usage.CompletionTokens, lastChunk.Usage.TotalTokens);
}
/// <summary>
/// Helper to parse chat completion chunks from SSE response.
/// </summary>
private static List<JsonElement> ParseChatCompletionChunksFromSse(string sseContent)
{
var chunks = new List<JsonElement>();
var lines = sseContent.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].TrimEnd('\r');
if (line.StartsWith("data: ", StringComparison.Ordinal))
{
var jsonData = line.Substring("data: ".Length);
// Skip [DONE] marker
if (jsonData == "[DONE]")
{
continue;
}
try
{
var doc = JsonDocument.Parse(jsonData);
chunks.Add(doc.RootElement.Clone());
}
catch
{
// Skip invalid JSON
}
}
}
return chunks;
}
#endregion
}
@@ -22,8 +22,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task BasicRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("basic/request.json");
using var expectedResponseDoc = LoadTraceDocument("basic/response.json");
string requestJson = LoadResponsesTraceFile("basic/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("basic/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get the expected response text from the trace to use as mock response
@@ -34,7 +34,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("basic-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "basic-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "basic-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -160,8 +160,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task ConversationRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("conversation/request.json");
using var expectedResponseDoc = LoadTraceDocument("conversation/response.json");
string requestJson = LoadResponsesTraceFile("conversation/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("conversation/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get the expected response text
@@ -172,7 +172,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("conversation-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "conversation-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "conversation-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -270,8 +270,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task ToolCallRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("tool_call/request.json");
using var expectedResponseDoc = LoadTraceDocument("tool_call/response.json");
string requestJson = LoadResponsesTraceFile("tool_call/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("tool_call/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get function call details from expected response
@@ -282,7 +282,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("tool-agent", "You are a helpful assistant.", functionName);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "tool-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "tool-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -429,8 +429,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task StreamingRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedResponseSse = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedResponseSse = LoadResponsesTraceFile("streaming/response.txt");
// Extract expected text from SSE events
var expectedEvents = ParseSseEventsFromContent(expectedResponseSse);
@@ -440,7 +440,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-agent", requestJson);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType);
@@ -634,8 +634,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task MetadataRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("metadata/request.json");
using var expectedResponseDoc = LoadTraceDocument("metadata/response.json");
string requestJson = LoadResponsesTraceFile("metadata/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("metadata/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get expected text (truncated due to max_output_tokens)
@@ -646,7 +646,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("metadata-agent", "Respond in a friendly, educational tone.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "metadata-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "metadata-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -761,8 +761,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task ReasoningRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("reasoning/request.json");
using var expectedResponseDoc = LoadTraceDocument("reasoning/response.json");
string requestJson = LoadResponsesTraceFile("reasoning/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("reasoning/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get expected text from the message output
@@ -773,7 +773,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("reasoning-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "reasoning-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "reasoning-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -843,8 +843,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task JsonOutputRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("json_output/request.json");
using var expectedResponseDoc = LoadTraceDocument("json_output/response.json");
string requestJson = LoadResponsesTraceFile("json_output/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("json_output/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get expected JSON text from response
@@ -855,7 +855,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("json-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "json-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "json-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -927,8 +927,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task RefusalRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("refusal/request.json");
using var expectedResponseDoc = LoadTraceDocument("refusal/response.json");
string requestJson = LoadResponsesTraceFile("refusal/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("refusal/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get expected refusal text
@@ -939,7 +939,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("refusal-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "refusal-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "refusal-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -986,8 +986,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task ImageInputRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("image_input/request.json");
using var expectedResponseDoc = LoadTraceDocument("image_input/response.json");
string requestJson = LoadResponsesTraceFile("image_input/request.json");
using var expectedResponseDoc = LoadResponsesTraceDocument("image_input/response.json");
var expectedResponse = expectedResponseDoc.RootElement;
// Get expected text
@@ -998,7 +998,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("image-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "image-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "image-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
@@ -1059,8 +1059,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task ReasoningStreamingRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("reasoning_streaming/request.json");
string expectedResponseSse = LoadTraceFile("reasoning_streaming/response.txt");
string requestJson = LoadResponsesTraceFile("reasoning_streaming/request.json");
string expectedResponseSse = LoadResponsesTraceFile("reasoning_streaming/response.txt");
// Extract expected text from SSE events
var expectedEvents = ParseSseEventsFromContent(expectedResponseSse);
@@ -1070,7 +1070,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("reasoning-streaming-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "reasoning-streaming-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "reasoning-streaming-agent", requestJson);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType);
@@ -1137,8 +1137,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task JsonOutputStreamingRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("json_output_streaming/request.json");
string expectedResponseSse = LoadTraceFile("json_output_streaming/response.txt");
string requestJson = LoadResponsesTraceFile("json_output_streaming/request.json");
string expectedResponseSse = LoadResponsesTraceFile("json_output_streaming/response.txt");
// Extract expected text from SSE events
var expectedEvents = ParseSseEventsFromContent(expectedResponseSse);
@@ -1148,7 +1148,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("json-streaming-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "json-streaming-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "json-streaming-agent", requestJson);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType);
@@ -1197,8 +1197,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task RefusalStreamingRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("refusal_streaming/request.json");
string expectedResponseSse = LoadTraceFile("refusal_streaming/response.txt");
string requestJson = LoadResponsesTraceFile("refusal_streaming/request.json");
string expectedResponseSse = LoadResponsesTraceFile("refusal_streaming/response.txt");
// Extract expected text from SSE events
var expectedEvents = ParseSseEventsFromContent(expectedResponseSse);
@@ -1208,7 +1208,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("refusal-streaming-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "refusal-streaming-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "refusal-streaming-agent", requestJson);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType);
@@ -1254,8 +1254,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
public async Task ImageInputStreamingRequestResponseAsync()
{
// Arrange
string requestJson = LoadTraceFile("image_input_streaming/request.json");
string expectedResponseSse = LoadTraceFile("image_input_streaming/response.txt");
string requestJson = LoadResponsesTraceFile("image_input_streaming/request.json");
string expectedResponseSse = LoadResponsesTraceFile("image_input_streaming/response.txt");
// Extract expected text from SSE events
var expectedEvents = ParseSseEventsFromContent(expectedResponseSse);
@@ -1265,7 +1265,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("image-streaming-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "image-streaming-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "image-streaming-agent", requestJson);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType);
@@ -22,7 +22,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_BasicRequest_Success()
{
// Arrange
string json = LoadTraceFile("basic/request.json");
string json = LoadResponsesTraceFile("basic/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -38,7 +38,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_BasicRequest_RoundTrip()
{
// Arrange
string originalJson = LoadTraceFile("basic/request.json");
string originalJson = LoadResponsesTraceFile("basic/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(originalJson, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -56,7 +56,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_StreamingRequest_HasStreamFlag()
{
// Arrange
string json = LoadTraceFile("streaming/request.json");
string json = LoadResponsesTraceFile("streaming/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -71,7 +71,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ConversationRequest_HasPreviousResponseId()
{
// Arrange
string json = LoadTraceFile("conversation/request.json");
string json = LoadResponsesTraceFile("conversation/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -86,7 +86,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_MetadataRequest_HasAllParameters()
{
// Arrange
string json = LoadTraceFile("metadata/request.json");
string json = LoadResponsesTraceFile("metadata/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -111,7 +111,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ToolCallRequest_HasToolDefinitions()
{
// Arrange
string json = LoadTraceFile("tool_call/request.json");
string json = LoadResponsesTraceFile("tool_call/request.json");
// Act
// CreateResponse doesn't have Tools property - it uses dynamic JSON
@@ -220,7 +220,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ImageInputRequest_HasImageData()
{
// Arrange
string json = LoadTraceFile("image_input/request.json");
string json = LoadResponsesTraceFile("image_input/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -234,7 +234,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ImageInputStreamingRequest_HasStreamAndImage()
{
// Arrange
string json = LoadTraceFile("image_input_streaming/request.json");
string json = LoadResponsesTraceFile("image_input_streaming/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -249,7 +249,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_JsonOutputRequest_HasJsonSchema()
{
// Arrange
string json = LoadTraceFile("json_output/request.json");
string json = LoadResponsesTraceFile("json_output/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -270,7 +270,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_JsonOutputStreamingRequest_HasJsonSchemaAndStream()
{
// Arrange
string json = LoadTraceFile("json_output_streaming/request.json");
string json = LoadResponsesTraceFile("json_output_streaming/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -290,7 +290,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ReasoningRequest_HasReasoningConfiguration()
{
// Arrange
string json = LoadTraceFile("reasoning/request.json");
string json = LoadResponsesTraceFile("reasoning/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -304,7 +304,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ReasoningStreamingRequest_HasReasoningAndStream()
{
// Arrange
string json = LoadTraceFile("reasoning_streaming/request.json");
string json = LoadResponsesTraceFile("reasoning_streaming/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -319,7 +319,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_RefusalRequest_CanBeDeserialized()
{
// Arrange
string json = LoadTraceFile("refusal/request.json");
string json = LoadResponsesTraceFile("refusal/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -333,7 +333,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_RefusalStreamingRequest_HasStream()
{
// Arrange
string json = LoadTraceFile("refusal_streaming/request.json");
string json = LoadResponsesTraceFile("refusal_streaming/request.json");
// Act
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -367,7 +367,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
foreach (var path in requestPaths)
{
string json = LoadTraceFile(path);
string json = LoadResponsesTraceFile(path);
// Act & Assert - Should not throw
CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse);
@@ -384,7 +384,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_BasicResponse_Success()
{
// Arrange
string json = LoadTraceFile("basic/response.json");
string json = LoadResponsesTraceFile("basic/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -403,7 +403,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_BasicResponse_HasCorrectOutput()
{
// Arrange
string json = LoadTraceFile("basic/response.json");
string json = LoadResponsesTraceFile("basic/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -426,7 +426,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_BasicResponse_HasCorrectUsage()
{
// Arrange
string json = LoadTraceFile("basic/response.json");
string json = LoadResponsesTraceFile("basic/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -445,7 +445,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ConversationResponse_HasPreviousResponseId()
{
// Arrange
string json = LoadTraceFile("conversation/response.json");
string json = LoadResponsesTraceFile("conversation/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -461,7 +461,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_MetadataResponse_PreservesMetadata()
{
// Arrange
string json = LoadTraceFile("metadata/response.json");
string json = LoadResponsesTraceFile("metadata/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -478,7 +478,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_MetadataResponse_HasIncompleteStatus()
{
// Arrange
string json = LoadTraceFile("metadata/response.json");
string json = LoadResponsesTraceFile("metadata/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -494,7 +494,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_MetadataResponse_HasInstructions()
{
// Arrange
string json = LoadTraceFile("metadata/response.json");
string json = LoadResponsesTraceFile("metadata/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -509,7 +509,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_MetadataResponse_HasModelParameters()
{
// Arrange
string json = LoadTraceFile("metadata/response.json");
string json = LoadResponsesTraceFile("metadata/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -525,7 +525,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ToolCallResponse_HasFunctionCall()
{
// Arrange
string json = LoadTraceFile("tool_call/response.json");
string json = LoadResponsesTraceFile("tool_call/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -549,7 +549,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ToolCallResponse_HasToolDefinitions()
{
// Arrange
string json = LoadTraceFile("tool_call/response.json");
string json = LoadResponsesTraceFile("tool_call/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -573,7 +573,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ImageInputResponse_HasImageInInput()
{
// Arrange
string json = LoadTraceFile("image_input/response.json");
string json = LoadResponsesTraceFile("image_input/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -588,7 +588,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_JsonOutputResponse_HasStructuredOutput()
{
// Arrange
string json = LoadTraceFile("json_output/response.json");
string json = LoadResponsesTraceFile("json_output/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -608,7 +608,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ReasoningResponse_HasReasoningItems()
{
// Arrange
string json = LoadTraceFile("reasoning/response.json");
string json = LoadResponsesTraceFile("reasoning/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -624,7 +624,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_RefusalResponse_HasRefusalContent()
{
// Arrange
string json = LoadTraceFile("refusal/response.json");
string json = LoadResponsesTraceFile("refusal/response.json");
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -653,7 +653,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
foreach (var path in responsePaths)
{
string json = LoadTraceFile(path);
string json = LoadResponsesTraceFile(path);
// Act
Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response);
@@ -672,7 +672,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void Deserialize_ResponseRoundTrip_PreservesData()
{
// Arrange
string originalJson = LoadTraceFile("basic/response.json");
string originalJson = LoadResponsesTraceFile("basic/response.json");
// Act - Deserialize and re-serialize
Response? response = JsonSerializer.Deserialize(originalJson, Responses.ResponsesJsonContext.Default.Response);
@@ -696,7 +696,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_BasicFormat_Success()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
// Act
var events = ParseSseEventsFromContent(sseContent);
@@ -715,7 +715,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_HasCorrectEventTypes()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
// Act
var events = ParseSseEventsFromContent(sseContent);
@@ -736,7 +736,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_DeserializeCreatedEvent_Success()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
var createdEventJson = events.First(e => e.GetProperty("type").GetString() == "response.created");
@@ -758,7 +758,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_DeserializeInProgressEvent_Success()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
var inProgressEventJson = events.First(e => e.GetProperty("type").GetString() == "response.in_progress");
@@ -779,7 +779,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_DeserializeOutputItemAdded_Success()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
var itemAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.output_item.added");
@@ -799,7 +799,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_DeserializeContentPartAdded_Success()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
var partAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.content_part.added");
@@ -821,7 +821,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_DeserializeTextDelta_Success()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
var textDeltaJson = events.First(e => e.GetProperty("type").GetString() == "response.output_text.delta");
@@ -843,7 +843,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_AccumulateTextDeltas_MatchesFinalText()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
// Act
@@ -877,7 +877,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_SequenceNumbersAreSequential()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
// Act
@@ -904,7 +904,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_FinalEvent_IsTerminalState()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
var events = ParseSseEventsFromContent(sseContent);
var lastEventJson = events.Last();
@@ -926,7 +926,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_ImageInputStreaming_HasImageEvents()
{
// Arrange
string sseContent = LoadTraceFile("image_input_streaming/response.txt");
string sseContent = LoadResponsesTraceFile("image_input_streaming/response.txt");
// Act
var events = ParseSseEventsFromContent(sseContent);
@@ -944,7 +944,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_JsonOutputStreaming_HasJsonSchemaEvents()
{
// Arrange
string sseContent = LoadTraceFile("json_output_streaming/response.txt");
string sseContent = LoadResponsesTraceFile("json_output_streaming/response.txt");
// Act
var events = ParseSseEventsFromContent(sseContent);
@@ -962,7 +962,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_ReasoningStreaming_HasReasoningEvents()
{
// Arrange
string sseContent = LoadTraceFile("reasoning_streaming/response.txt");
string sseContent = LoadResponsesTraceFile("reasoning_streaming/response.txt");
// Act
var events = ParseSseEventsFromContent(sseContent);
@@ -983,7 +983,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_RefusalStreaming_HasRefusalEvents()
{
// Arrange
string sseContent = LoadTraceFile("refusal_streaming/response.txt");
string sseContent = LoadResponsesTraceFile("refusal_streaming/response.txt");
// Act
var events = ParseSseEventsFromContent(sseContent);
@@ -1014,7 +1014,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
foreach (var path in streamingPaths)
{
string sseContent = LoadTraceFile(path);
string sseContent = LoadResponsesTraceFile(path);
// Act & Assert
foreach (var eventJson in ParseSseEventsFromContent(sseContent))
@@ -1030,7 +1030,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
public void ParseStreamingEvents_AllEvents_CanBeDeserialized()
{
// Arrange
string sseContent = LoadTraceFile("streaming/response.txt");
string sseContent = LoadResponsesTraceFile("streaming/response.txt");
// Act & Assert
foreach (var eventJson in ParseSseEventsFromContent(sseContent))
@@ -24,8 +24,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_BasicFormat_SuccessAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
// Extract expected text
var expectedEvents = ParseSseEvents(expectedSseContent);
@@ -35,7 +35,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-basic-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-basic-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-basic-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
// Act
@@ -55,8 +55,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_HasCorrectEventTypesAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -65,7 +65,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-types-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-types-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-types-agent", requestJson);
// Assert - HTTP response validation
Assert.Equal(System.Net.HttpStatusCode.OK, httpResponse.StatusCode);
@@ -118,8 +118,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_DeserializeCreatedEvent_SuccessAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -128,7 +128,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-created-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-created-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-created-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
var createdEventJson = events.First(e => e.GetProperty("type").GetString() == "response.created");
@@ -151,8 +151,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_DeserializeInProgressEvent_SuccessAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -161,7 +161,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-progress-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-progress-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-progress-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
var inProgressEventJson = events.First(e => e.GetProperty("type").GetString() == "response.in_progress");
@@ -183,8 +183,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_DeserializeOutputItemAdded_SuccessAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -193,7 +193,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-item-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-item-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-item-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
var itemAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.output_item.added");
@@ -214,8 +214,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_DeserializeContentPartAdded_SuccessAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -224,7 +224,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-part-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-part-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-part-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
var partAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.content_part.added");
@@ -247,8 +247,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_DeserializeTextDelta_SuccessAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -257,7 +257,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-delta-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-delta-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-delta-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
var textDeltaJson = events.First(e => e.GetProperty("type").GetString() == "response.output_text.delta");
@@ -280,8 +280,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_AccumulateTextDeltas_MatchesFinalTextAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -290,7 +290,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-accumulate-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-accumulate-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-accumulate-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -325,8 +325,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_SequenceNumbersAreSequentialAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -335,7 +335,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-sequence-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-sequence-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-sequence-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -363,8 +363,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_FinalEvent_IsTerminalStateAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -373,7 +373,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-terminal-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-terminal-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-terminal-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
var lastEventJson = events.Last();
@@ -396,8 +396,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_AllEvents_CanBeDeserializedAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -406,7 +406,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-deserialize-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-deserialize-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-deserialize-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
// Act & Assert
@@ -439,8 +439,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_IdConsistency_ValidAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -449,7 +449,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-id-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-id-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-id-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -530,8 +530,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_IndexConsistency_ValidAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -540,7 +540,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-index-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-index-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-index-agent", requestJson);
// Assert - All events with output_index should have valid values
foreach (var eventJson in ParseSseEvents(await httpResponse.Content.ReadAsStringAsync()))
@@ -587,8 +587,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_ResponseObjectEvolution_ValidAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -597,7 +597,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-evolution-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-evolution-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-evolution-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -655,8 +655,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_SseFormatCompliance_ValidAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -665,7 +665,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-sse-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-sse-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-sse-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - SSE format validation
@@ -699,8 +699,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_EventPairing_ValidAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -709,7 +709,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-pairing-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-pairing-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-pairing-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -755,8 +755,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
public async Task ParseStreamingEvents_NoDuplicateSequenceNumbers_ValidAsync()
{
// Arrange
string requestJson = LoadTraceFile("streaming/request.json");
string expectedSseContent = LoadTraceFile("streaming/response.txt");
string requestJson = LoadResponsesTraceFile("streaming/request.json");
string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt");
var expectedEvents = ParseSseEvents(expectedSseContent);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
@@ -765,7 +765,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase
HttpClient client = await this.CreateTestServerAsync("streaming-nodup-agent", "You are a helpful assistant.", expectedText);
// Act
HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-nodup-agent", requestJson);
HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-nodup-agent", requestJson);
string sseContent = await httpResponse.Content.ReadAsStringAsync();
var events = ParseSseEvents(sseContent);
@@ -233,6 +233,197 @@ public class HostApplicationBuilderWorkflowExtensionsTests
Assert.Equal("agentBuilders", exception.ParamName);
}
/// <summary>
/// Verifies that AddAsAIAgent without a name parameter uses the workflow name as the agent name.
/// </summary>
[Fact]
public void AddAsAIAgent_WithoutName_UsesWorkflowName()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
var agentBuilder = workflowBuilder.AddAsAIAgent();
Assert.NotNull(agentBuilder);
// Verify workflow is registered with workflow name
var workflowDescriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow));
Assert.NotNull(workflowDescriptor);
// Verify agent is registered with workflow name
var agentDescriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent));
Assert.NotNull(agentDescriptor);
}
/// <summary>
/// Verifies that AddAsAIAgent with a name parameter uses that name instead of the workflow name.
/// </summary>
[Fact]
public void AddAsAIAgent_WithName_UsesProvidedName()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
const string AgentName = "testAgent";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
var agentBuilder = workflowBuilder.AddAsAIAgent(AgentName);
Assert.NotNull(agentBuilder);
// Verify workflow is registered with workflow name
var workflowDescriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow));
Assert.NotNull(workflowDescriptor);
// Verify agent is registered with agent name (not workflow name)
var agentDescriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == AgentName && d.ServiceType == typeof(AIAgent));
Assert.NotNull(agentDescriptor);
// Verify no agent registered with workflow name
var wrongAgentDescriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent));
Assert.NotSame(workflowDescriptor, wrongAgentDescriptor);
}
/// <summary>
/// Verifies that AddAsAIAgent correctly retrieves the workflow using the workflow name, not the agent name.
/// </summary>
[Fact]
public void AddAsAIAgent_WithDifferentName_RetrievesWorkflowCorrectly()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "myWorkflow";
const string AgentName = "myAgent";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
workflowBuilder.AddAsAIAgent(AgentName);
var serviceProvider = builder.Build().Services;
// Act - Get the agent using the agent name
var agent = serviceProvider.GetRequiredKeyedService<AIAgent>(AgentName);
Assert.NotNull(agent);
Assert.Equal(AgentName, agent.Name);
// Verify that we can still get the workflow using the workflow name
var workflow = serviceProvider.GetRequiredKeyedService<Workflow>(WorkflowName);
Assert.NotNull(workflow);
Assert.Equal(WorkflowName, workflow.Name);
}
/// <summary>
/// Verifies that AddAsAIAgent returns IHostedAgentBuilder with correct name.
/// </summary>
[Fact]
public void AddAsAIAgent_ReturnsHostedAgentBuilder()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
const string AgentName = "testAgent";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
var agentBuilder = workflowBuilder.AddAsAIAgent(AgentName);
Assert.NotNull(agentBuilder);
Assert.IsAssignableFrom<IHostedAgentBuilder>(agentBuilder);
Assert.Equal(AgentName, agentBuilder.Name);
}
/// <summary>
/// Verifies that AddAsAIAgent without name returns IHostedAgentBuilder with workflow name.
/// </summary>
[Fact]
public void AddAsAIAgent_WithoutName_ReturnsHostedAgentBuilderWithWorkflowName()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
var agentBuilder = workflowBuilder.AddAsAIAgent();
Assert.NotNull(agentBuilder);
Assert.IsAssignableFrom<IHostedAgentBuilder>(agentBuilder);
Assert.Equal(WorkflowName, agentBuilder.Name);
}
/// <summary>
/// Verifies that AddAsAIAgent can chain multiple agents from the same workflow.
/// </summary>
[Fact]
public void AddAsAIAgent_MultipleAgents_FromSameWorkflow()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
var agentBuilder1 = workflowBuilder.AddAsAIAgent("agent1");
var agentBuilder2 = workflowBuilder.AddAsAIAgent("agent2");
Assert.NotNull(agentBuilder1);
Assert.NotNull(agentBuilder2);
// Verify both agents are registered
var agentDescriptor1 = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent1" && d.ServiceType == typeof(AIAgent));
var agentDescriptor2 = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == "agent2" && d.ServiceType == typeof(AIAgent));
Assert.NotNull(agentDescriptor1);
Assert.NotNull(agentDescriptor2);
// Verify workflow is registered only once
var workflowDescriptors = builder.Services.Where(
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow)).ToList();
Assert.Single(workflowDescriptors);
}
/// <summary>
/// Verifies that AddAsAIAgent with null name behaves the same as the parameterless overload.
/// </summary>
[Fact]
public void AddAsAIAgent_WithNullName_UsesWorkflowName()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
var agentBuilder = workflowBuilder.AddAsAIAgent(name: null);
Assert.NotNull(agentBuilder);
Assert.Equal(WorkflowName, agentBuilder.Name);
// Verify agent is registered with workflow name
var agentDescriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent));
Assert.NotNull(agentDescriptor);
}
/// <summary>
/// Verifies that AddAsAIAgent with empty string name uses empty string as agent name.
/// </summary>
[Fact]
public void AddAsAIAgent_WithEmptyName_UsesEmptyStringAsAgentName()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "testWorkflow";
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
var agentBuilder = workflowBuilder.AddAsAIAgent(name: "");
Assert.NotNull(agentBuilder);
Assert.Equal("", agentBuilder.Name);
// Verify agent is registered with empty string name
var agentDescriptor = builder.Services.FirstOrDefault(
d => d.ServiceKey is string s && s.Length == 0 && d.ServiceType == typeof(AIAgent));
Assert.NotNull(agentDescriptor);
}
/// <summary>
/// Helper method to create a simple test workflow with a given name.
/// </summary>
@@ -45,8 +45,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is my name?");
var input = new ChatMessage(ChatRole.User, "Hello, my name is Caoimhe.");
var options = new Mem0ProviderOptions { ThreadId = "it-thread-1", UserId = "it-user-1" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ThreadId = "it-thread-1", UserId = "it-user-1" };
var sut = new Mem0Provider(this._httpClient, storageScope);
await sut.ClearStoredMemoriesAsync();
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -69,8 +69,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is your name?");
var assistantIntro = new ChatMessage(ChatRole.Assistant, "Hello, I'm a friendly assistant and my name is Caoimhe.");
var options = new Mem0ProviderOptions { AgentId = "it-agent-1" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { AgentId = "it-agent-1" };
var sut = new Mem0Provider(this._httpClient, storageScope);
await sut.ClearStoredMemoriesAsync();
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -93,8 +93,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is your name?");
var assistantIntro = new ChatMessage(ChatRole.Assistant, "I'm an AI tutor and my name is Caoimhe.");
var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-a" });
var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-b" });
var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-a" });
var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-b" });
await sut1.ClearStoredMemoriesAsync();
await sut2.ClearStoredMemoriesAsync();
@@ -48,35 +48,35 @@ public sealed class Mem0ProviderTests : IDisposable
using HttpClient client = new();
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(client));
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(client, new Mem0ProviderScope() { ThreadId = "tid" }));
Assert.StartsWith("The HttpClient BaseAddress must be set for Mem0 operations.", ex.Message);
}
[Fact]
public void Constructor_Defaults_Scopes()
public void Constructor_Throws_WhenNoStorageScopeValueIsSet()
{
// Arrange & Act
var sut = new Mem0Provider(this._httpClient);
// Assert
Assert.Null(sut.ApplicationId);
Assert.Null(sut.AgentId);
Assert.Null(sut.ThreadId);
Assert.Null(sut.UserId);
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope()));
Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope.", ex.Message);
}
[Fact]
public void DeserializingConstructor_Defaults_Scopes()
public void Constructor_Throws_WhenNoSearchScopeValueIsSet()
{
// Arrange & Act
var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions);
var sut = new Mem0Provider(this._httpClient, jsonElement);
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope() { ThreadId = "tid" }, new Mem0ProviderScope()));
Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope.", ex.Message);
}
// Assert
Assert.Null(sut.ApplicationId);
Assert.Null(sut.AgentId);
Assert.Null(sut.ThreadId);
Assert.Null(sut.UserId);
[Fact]
public void DeserializingConstructor_Throws_WithEmptyJsonElement()
{
// Arrange
var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => new Mem0Provider(this._httpClient, jsonElement));
Assert.StartsWith("The Mem0Provider state did not contain the required scope properties.", ex.Message);
}
[Fact]
@@ -84,14 +84,14 @@ public sealed class Mem0ProviderTests : IDisposable
{
// Arrange
this._handler.EnqueueJsonResponse("[ { \"id\": \"1\", \"memory\": \"Name is Caoimhe\", \"hash\": \"h\", \"metadata\": null, \"score\": 0.9, \"created_at\": \"2023-01-01T00:00:00Z\", \"updated_at\": null, \"user_id\": \"u\", \"app_id\": null, \"agent_id\": \"agent\", \"session_id\": \"thread\" } ]");
var options = new Mem0ProviderOptions
var storageScope = new Mem0ProviderScope
{
ApplicationId = "app",
AgentId = "agent",
ThreadId = "thread",
UserId = "user"
};
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") });
// Act
@@ -137,8 +137,8 @@ public sealed class Mem0ProviderTests : IDisposable
this._handler.EnqueueEmptyOk(); // For first CreateMemory
this._handler.EnqueueEmptyOk(); // For second CreateMemory
this._handler.EnqueueEmptyOk(); // For third CreateMemory
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope);
var requestMessages = new List<ChatMessage>
{
@@ -168,8 +168,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task InvokedAsync_PersistsNothingForFailedRequestAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope);
var requestMessages = new List<ChatMessage>
{
@@ -189,8 +189,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
this._handler.EnqueueEmptyInternalServerError();
var requestMessages = new List<ChatMessage>
@@ -222,8 +222,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, storageScope);
this._handler.EnqueueEmptyOk(); // for DELETE
// Act
@@ -235,80 +235,39 @@ public sealed class Mem0ProviderTests : IDisposable
}
[Fact]
public void Properties_Roundtrip()
public void Serialize_RoundTripsScopes()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
// Assert
Assert.Equal("app", sut.ApplicationId);
Assert.Equal("agent", sut.AgentId);
Assert.Equal("thread", sut.ThreadId);
Assert.Equal("user", sut.UserId);
// Act
sut.ApplicationId = "app2";
sut.AgentId = "agent2";
sut.ThreadId = "thread2";
sut.UserId = "user2";
// Assert
Assert.Equal("app2", sut.ApplicationId);
Assert.Equal("agent2", sut.AgentId);
Assert.Equal("thread2", sut.ThreadId);
Assert.Equal("user2", sut.UserId);
}
[Fact]
public void Serialize_Deserialize_Roundtrips()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
// Act
var stateElement = sut.Serialize();
var sut2 = new Mem0Provider(this._httpClient, stateElement);
// Assert
Assert.Equal("app", sut.ApplicationId);
Assert.Equal("agent", sut.AgentId);
Assert.Equal("thread", sut.ThreadId);
Assert.Equal("user", sut.UserId);
Assert.Equal("app", sut2.ApplicationId);
Assert.Equal("agent", sut2.AgentId);
Assert.Equal("thread", sut2.ThreadId);
Assert.Equal("user", sut2.UserId);
}
[Fact]
public void Serialize_RoundTripsCustomContextPrompt()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user", ContextPrompt = "Custom:" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, storageScope, options: new() { ContextPrompt = "Custom:" }, loggerFactory: this._loggerFactoryMock.Object);
// Act
var stateElement = sut.Serialize();
using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText());
Assert.Equal("Custom:", doc.RootElement.GetProperty("contextPrompt").GetString());
var storageScopeElement = doc.RootElement.GetProperty("storageScope");
Assert.Equal("app", storageScopeElement.GetProperty("applicationId").GetString());
Assert.Equal("agent", storageScopeElement.GetProperty("agentId").GetString());
Assert.Equal("thread", storageScopeElement.GetProperty("threadId").GetString());
Assert.Equal("user", storageScopeElement.GetProperty("userId").GetString());
var sut2 = new Mem0Provider(this._httpClient, stateElement);
var stateElement2 = sut2.Serialize();
// Assert
using JsonDocument doc2 = JsonDocument.Parse(stateElement2.GetRawText());
Assert.Equal("Custom:", doc2.RootElement.GetProperty("contextPrompt").GetString());
var storageScopeElement2 = doc2.RootElement.GetProperty("storageScope");
Assert.Equal("app", storageScopeElement2.GetProperty("applicationId").GetString());
Assert.Equal("agent", storageScopeElement2.GetProperty("agentId").GetString());
Assert.Equal("thread", storageScopeElement2.GetProperty("threadId").GetString());
Assert.Equal("user", storageScopeElement2.GetProperty("userId").GetString());
}
[Fact]
public void Serialize_DoesNotIncludeDefaultContextPrompt()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app" };
var sut = new Mem0Provider(this._httpClient, storageScope);
// Act
var stateElement = sut.Serialize();
@@ -318,23 +277,12 @@ public sealed class Mem0ProviderTests : IDisposable
Assert.False(doc.RootElement.TryGetProperty("contextPrompt", out _));
}
[Fact]
public async Task InvokingAsync_Throws_WhenNoScopesAsync()
{
// Arrange
var sut = new Mem0Provider(this._httpClient, new Mem0ProviderOptions());
var ctx = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Test") });
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => sut.InvokingAsync(ctx).AsTask());
}
[Fact]
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app" };
var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object);
var storageScope = new Mem0ProviderScope { ApplicationId = "app" };
var provider = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -58,7 +58,7 @@ public sealed class TextSearchProviderTests
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options, withLogging ? this._loggerFactoryMock.Object : null);
var invokingContext = new AIContextProvider.InvokingContext(new[]
{
@@ -135,7 +135,7 @@ public sealed class TextSearchProviderTests
FunctionToolName = overrideName,
FunctionToolDescription = overrideDescription
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -154,7 +154,7 @@ public sealed class TextSearchProviderTests
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object);
var provider = new TextSearchProvider(this.FailingSearchAsync, default, null, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -195,7 +195,7 @@ public sealed class TextSearchProviderTests
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Act
var formatted = await provider.SearchAsync("Sample user question?", CancellationToken.None);
@@ -247,7 +247,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => $"Custom formatted context with {r.Count} results."
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -281,7 +281,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => string.Join(",", r.Select(x => ((RawPayload)x.RawRepresentation!).Id))
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -298,7 +298,7 @@ public sealed class TextSearchProviderTests
{
// Arrange
var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke };
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -327,7 +327,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
@@ -367,7 +367,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
@@ -407,7 +407,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// First memory update (A,B)
await provider.InvokedAsync(new(new[]
@@ -449,7 +449,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained.
var initialMessages = new[]
@@ -486,7 +486,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
// Act
var state = provider.Serialize();
@@ -506,7 +506,7 @@ public sealed class TextSearchProviderTests
RecentMessageMemoryLimit = 3,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "M1"),
@@ -536,7 +536,7 @@ public sealed class TextSearchProviderTests
RecentMessageMemoryLimit = 4,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "A"),
@@ -571,7 +571,7 @@ public sealed class TextSearchProviderTests
public async Task Deserialize_WithChangedLowerLimit_ShouldTruncateToNewLimitAsync()
{
// Arrange
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, default, null, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 5,
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Mock implementation of <see cref="WorkflowAgentProvider"/> for unit testing purposes.
/// </summary>
internal sealed class MockAgentProvider : Mock<WorkflowAgentProvider>
{
public IList<string> ExistingConversationIds { get; } = [];
public MockAgentProvider()
{
this.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(this.CreateConversationId()));
}
private string CreateConversationId()
{
string newConversationId = Guid.NewGuid().ToString("N");
this.ExistingConversationIds.Add(newConversationId);
return newConversationId;
}
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="CreateConversationExecutor "/>.
/// </summary>
public sealed class CreateConversationExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CreateNewConversationAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateNewConversationAsync),
"TestConversationId",
executionIteration: 1);
}
[Fact]
public async Task CreateMultipleConversationsAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateMultipleConversationsAsync),
"TestConversationId",
executionIteration: 4);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
int executionIteration)
{
// Arrange
// Initialize state to simulate workflow environment.
this.State.InitializeSystem();
CreateConversation model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName));
MockAgentProvider mockAgentProvider = new();
CreateConversationExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
int expectedIterationCount = executionIteration;
while (executionIteration-- > 0)
{
await this.ExecuteAsync(action);
}
// Assert
VerifyModel(model, action);
Assert.Equal(expected: expectedIterationCount, actual: mockAgentProvider.ExistingConversationIds.Count);
this.VerifyState("TestConversationId", FormulaValue.New(mockAgentProvider.ExistingConversationIds.Last()));
}
private CreateConversation CreateModel(string displayName, string conversationIdVariable)
{
CreateConversation.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ConversationId = PropertyPath.Create(conversationIdVariable)
};
return AssignParent<CreateConversation>(actionBuilder);
}
}
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// These tests ensure that OpenTelemetry Activity traces are properly created for workflow monitoring.
/// Tests are run in a collection to avoid parallel execution since ActivityListener is global.
/// Each test creates a new instance of ObservabilityTests and runs in serial within the collection.
/// This prevents interference between tests due to the global nature of ActivityListener.
/// </summary>
[Collection("ObservabilityTests")]
public sealed class ObservabilityTests : IDisposable
{
private readonly ActivityListener _activityListener;
private readonly ConcurrentBag<Activity> _capturedActivities = [];
private bool _isDisposed;
public ObservabilityTests()
{
// Set up activity listener to capture activities from workflow
// This is global and captures ALL workflow activities from ANY test in the same process!
this._activityListener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains(typeof(Workflow).Namespace!),
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
ActivityStarted = activity => this._capturedActivities.Add(activity),
};
ActivitySource.AddActivityListener(this._activityListener);
}
/// <summary>
/// Create a sample workflow for testing.
/// </summary>
/// <remarks>
/// This workflow is expected to create 8 activities that will be captured by the tests
/// - ActivityNames.WorkflowBuild
/// - ActivityNames.WorkflowRun
/// -- ActivityNames.EdgeGroupProcess
/// -- ActivityNames.ExecutorProcess (UppercaseExecutor)
/// --- ActivityNames.MessageSend
/// ---- ActivityNames.EdgeGroupProcess
/// -- ActivityNames.ExecutorProcess (ReverseTextExecutor)
/// --- ActivityNames.MessageSend
/// </remarks>
/// <returns>The created workflow.</returns>
private static Workflow CreateWorkflow()
{
// Create the executors
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
Func<string, string> reverseFunc = s => new string(s.Reverse().ToArray());
var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor");
// Build the workflow by connecting executors sequentially
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
return builder.Build();
}
private static Dictionary<string, int> GetExpectedActivityNameCounts() =>
new()
{
{ ActivityNames.WorkflowBuild, 1 },
{ ActivityNames.WorkflowRun, 1 },
{ ActivityNames.EdgeGroupProcess, 2 },
{ ActivityNames.ExecutorProcess, 2 },
{ ActivityNames.MessageSend, 2 }
};
private static InProcessExecutionEnvironment GetExecutionEnvironment(string name) =>
name switch
{
"Default" => InProcessExecution.Default,
"Lockstep" => InProcessExecution.Lockstep,
"OffThread" => InProcessExecution.OffThread,
"Concurrent" => InProcessExecution.Concurrent,
_ => throw new ArgumentException($"Unknown execution environment name: {name}")
};
public void Dispose()
{
if (!this._isDisposed)
{
this._activityListener?.Dispose();
this._isDisposed = true;
}
}
private async Task TestWorkflowEndToEndActivitiesAsync(string executionEnvironmentName)
{
// Arrange
// Create a test activity to correlate captured activities
using var testActivity = new Activity("ObservabilityTest").Start();
// Act
var workflow = CreateWorkflow();
var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName);
Run run = await executionEnvironment.RunAsync(workflow, "Hello, World!");
await run.DisposeAsync();
await Task.Delay(100); // Allow time for activities to be captured
// Assert
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
capturedActivities.Should().HaveCount(8, "Exactly 8 activities should be created.");
// Make sure all expected activities exist and have the correct count
foreach (var kvp in GetExpectedActivityNameCounts())
{
var activityName = kvp.Key;
var expectedCount = kvp.Value;
var actualCount = capturedActivities.Count(a => a.OperationName == activityName);
actualCount.Should().Be(expectedCount, $"Activity '{activityName}' should occur {expectedCount} times.");
}
// Verify WorkflowRun activity events include workflow lifecycle events
var workflowRunActivity = capturedActivities.First(a => a.OperationName == ActivityNames.WorkflowRun);
var activityEvents = workflowRunActivity.Events.ToList();
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowStarted, "activity should have workflow started event");
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Default");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Concurrent");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Lockstep");
}
[Fact]
public async Task CreatesWorkflowActivities_WithCorrectNameAsync()
{
// Arrange
// Create a test activity to correlate captured activities
using var testActivity = new Activity("ObservabilityTest").Start();
// Act
CreateWorkflow();
await Task.Delay(100); // Allow time for activities to be captured
// Assert
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
capturedActivities.Should().HaveCount(1, "Exactly 1 activity should be created.");
capturedActivities[0].OperationName.Should().Be(ActivityNames.WorkflowBuild,
"The activity should have the correct operation name for workflow build.");
var events = capturedActivities[0].Events.ToList();
events.Should().Contain(e => e.Name == EventNames.BuildStarted, "activity should have build started event");
events.Should().Contain(e => e.Name == EventNames.BuildValidationCompleted, "activity should have build validation completed event");
events.Should().Contain(e => e.Name == EventNames.BuildCompleted, "activity should have build completed event");
var tags = capturedActivities[0].Tags.ToDictionary(t => t.Key, t => t.Value);
tags.Should().ContainKey(Tags.WorkflowId);
tags.Should().ContainKey(Tags.WorkflowDefinition);
}
}

Some files were not shown because too many files have changed in this diff Show More