mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-python-foundry-agents
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -203,4 +203,12 @@ agents.md
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
WARP.md
|
||||
WARP.md
|
||||
|
||||
# Frontend
|
||||
**/frontend/node_modules/
|
||||
**/frontend/.vite/
|
||||
**/frontend/dist/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-3
@@ -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();
|
||||
|
||||
+1
-3
@@ -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();
|
||||
|
||||
+1
-1
@@ -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)
|
||||
});
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+24
@@ -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>
|
||||
+409
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
-55
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+209
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+118
@@ -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));
|
||||
}
|
||||
}
|
||||
+59
@@ -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/*";
|
||||
}
|
||||
}
|
||||
+68
@@ -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; }
|
||||
}
|
||||
+216
@@ -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; }
|
||||
}
|
||||
+121
@@ -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; }
|
||||
}
|
||||
+175
@@ -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");
|
||||
}
|
||||
}
|
||||
+133
@@ -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
|
||||
};
|
||||
}
|
||||
+258
@@ -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; }
|
||||
}
|
||||
+167
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -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; }
|
||||
}
|
||||
+282
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-52
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
-38
@@ -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; }
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+12
@@ -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
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+43
@@ -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"
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+12
@@ -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
|
||||
}
|
||||
+21
@@ -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]
|
||||
+14
@@ -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
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+19
-19
@@ -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);
|
||||
|
||||
|
||||
+13
-1
@@ -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>
|
||||
|
||||
+495
@@ -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;
|
||||
}
|
||||
}
|
||||
+974
@@ -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();
|
||||
}
|
||||
}
|
||||
+576
@@ -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
|
||||
}
|
||||
+39
-39
@@ -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);
|
||||
|
||||
+47
-47
@@ -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))
|
||||
|
||||
+51
-51
@@ -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);
|
||||
|
||||
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
@@ -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
Reference in New Issue
Block a user