Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .github/workflows/dotnet-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,15 @@ jobs:
run: |
export UT_PROJECTS=$(find ./dotnet -type f -name "*.UnitTests.csproj" | tr '\n' ' ')
for project in $UT_PROJECTS; do
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
# Query the project's target frameworks using MSBuild with the current configuration
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')

# Check if the project supports the target framework
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
else
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
fi
done

- name: Log event name and matrix integration-tests
Expand All @@ -148,7 +156,15 @@ jobs:
run: |
export INTEGRATION_TEST_PROJECTS=$(find ./dotnet -type f -name "*IntegrationTests.csproj" | tr '\n' ' ')
for project in $INTEGRATION_TEST_PROJECTS; do
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx
# Query the project's target frameworks using MSBuild with the current configuration
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')

# Check if the project supports the target framework
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx
else
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
fi
done
env:
# OpenAI Models
Expand Down
7 changes: 5 additions & 2 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,18 @@
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="System.ClientModel" Version="1.7.0" />
<PackageVersion Include="System.CodeDom" Version="9.0.10" />
<PackageVersion Include="System.Collections.Immutable" Version="9.0.10" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.10" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.10" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<!-- OpenTelemetry -->
<PackageVersion Include="OpenTelemetry" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.13.1" />
Expand Down Expand Up @@ -93,6 +95,7 @@
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<!-- Test -->
<PackageVersion Include="FluentAssertions" Version="8.7.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Abstractions" Version="1.66.0" />
Expand Down
1 change: 1 addition & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@
<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.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" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
Expand Down
9 changes: 2 additions & 7 deletions dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.A2A.AspNetCore;
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;

Expand Down Expand Up @@ -81,6 +80,7 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te

builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
builder.AddOpenAIResponses();

var app = builder.Build();

Expand All @@ -102,16 +102,11 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
// Url = "http://localhost:5390/a2a/knights-and-knaves"
});

app.MapOpenAIResponses("pirate");
app.MapOpenAIResponses("knights-and-knaves");
app.MapOpenAIResponses();

app.MapOpenAIChatCompletions("pirate");
app.MapOpenAIChatCompletions("knights-and-knaves");

// workflow-agents
app.MapOpenAIResponses("science-sequential-workflow");
app.MapOpenAIResponses("science-concurrent-workflow");

// Map the agents HTTP endpoints
app.MapAgentDiscovery("/agents");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ public async override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync
{
OpenAIClientOptions options = new()
{
Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"),
Endpoint = new Uri(httpClient.BaseAddress!, "/v1/"),
Transport = new HttpClientPipelineTransport(httpClient)
};

var openAiClient = new OpenAIResponseClient(model: "myModel!", credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient();
var openAiClient = new OpenAIResponseClient(model: agentName, credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient();
var chatOptions = new ChatOptions()
{
ConversationId = threadId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,41 +12,41 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils;
[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?> _getStreamNullable;
private static readonly Func<ChatCompletionOptions, IList<ChatMessage>> _getMessages;
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.");
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.");

_getStreamNullable = streamGetter.CreateDelegate<Func<ChatCompletionOptions, bool?>>();
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);
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.");
?? throw new MissingMethodException($"{InputPropName} getter not found.");

_getMessages = inputGetter.CreateDelegate<Func<ChatCompletionOptions, IList<ChatMessage>>>();
s_getMessages = inputGetter.CreateDelegate<Func<ChatCompletionOptions, IList<ChatMessage>>>();
}

public static IList<ChatMessage> GetMessages(this ChatCompletionOptions options)
{
Throw.IfNull(options);
return _getMessages(options);
return s_getMessages(options);
}

public static bool GetStream(this ChatCompletionOptions options)
{
Throw.IfNull(options);
return _getStreamNullable(options) ?? false;
return s_getStreamNullable(options) ?? false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using OpenAI.Chat;

namespace Microsoft.Agents.AI.Hosting.OpenAI;
namespace Microsoft.AspNetCore.Builder;

public static partial class EndpointRouteBuilderExtensions
public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,90 +1,94 @@
// 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.OpenAI.Responses;
using Microsoft.AspNetCore.Builder;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using OpenAI.Responses;

namespace Microsoft.Agents.AI.Hosting.OpenAI;
namespace Microsoft.AspNetCore.Builder;

/// <summary>
/// Provides extension methods for mapping OpenAI capabilities to an <see cref="AIAgent"/>.
/// </summary>
public static partial class EndpointRouteBuilderExtensions
public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps OpenAI Responses 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 Responses 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="agent">The <see cref="AIAgent"/> instance to map the OpenAI Responses endpoints for.</param>
public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints, AIAgent agent) =>
MapOpenAIResponses(endpoints, agent, responsesPath: null);

/// <summary>
/// Maps OpenAI Responses 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 Responses endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI Responses endpoints for.</param>
/// <param name="responsesPath">Custom route path for the responses endpoint.</param>
/// <param name="conversationsPath">Custom route path for the conversations endpoint.</param>
public static void MapOpenAIResponses(
public static IEndpointConventionBuilder MapOpenAIResponses(
this IEndpointRouteBuilder endpoints,
string agentName,
[StringSyntax("Route")] string? responsesPath = null,
[StringSyntax("Route")] string? conversationsPath = null)
AIAgent agent,
[StringSyntax("Route")] string? responsesPath)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentName);
if (responsesPath is null || conversationsPath is null)
{
ValidateAgentName(agentName);
}

var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent.Name));
ValidateAgentName(agent.Name);

responsesPath ??= $"/{agentName}/v1/responses";
var responsesRouteGroup = endpoints.MapGroup(responsesPath);
MapResponses(responsesRouteGroup, agent);

// Will be included once we obtain the API to operate with thread (conversation).

// conversationsPath ??= $"/{agentName}/v1/conversations";
// var conversationsRouteGroup = endpoints.MapGroup(conversationsPath);
// MapConversations(conversationsRouteGroup, agent, loggerFactory);
responsesPath ??= $"/{agent.Name}/v1/responses";
var group = endpoints.MapGroup(responsesPath);
var endpointAgentName = agent.DisplayName;
group.MapPost("/", async ([FromBody] CreateResponse createResponse, CancellationToken cancellationToken)
=> await AIAgentResponsesProcessor.CreateModelResponseAsync(agent, createResponse, cancellationToken).ConfigureAwait(false))
.WithName(endpointAgentName + "/CreateResponse");
return group;
}

private static void MapResponses(IEndpointRouteBuilder routeGroup, AIAgent agent)
/// <summary>
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteBuilder endpoints) =>
MapOpenAIResponses(endpoints, responsesPath: null);

/// <summary>
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
/// <param name="responsesPath">Custom route path for the responses endpoint.</param>
public static IEndpointConventionBuilder MapOpenAIResponses(
this IEndpointRouteBuilder endpoints,
[StringSyntax("Route")] string? responsesPath)
{
var endpointAgentName = agent.DisplayName;
var responsesProcessor = new AIAgentResponsesProcessor(agent);
ArgumentNullException.ThrowIfNull(endpoints);

routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) =>
responsesPath ??= "/v1/responses";
var group = endpoints.MapGroup(responsesPath);
group.MapPost("/", async ([FromBody] CreateResponse createResponse, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false);

var responseOptions = new ResponseCreationOptions();
var responseOptionsJsonModel = responseOptions as IJsonModel<ResponseCreationOptions>;
Debug.Assert(responseOptionsJsonModel is not null);

responseOptions = responseOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json);
if (responseOptions is null)
// DevUI uses the 'model' field to specify the agent name.
var agentName = createResponse.Agent?.Name ?? createResponse.Model;
if (agentName is null)
{
return Results.BadRequest("Invalid request payload.");
return Results.BadRequest("No 'agent.name' or 'model' specified in the request.");
}

return await responsesProcessor.CreateModelResponseAsync(responseOptions, cancellationToken).ConfigureAwait(false);
}).WithName(endpointAgentName + "/CreateResponse");
}

#pragma warning disable IDE0051 // Remove unused private members
private static void MapConversations(IEndpointRouteBuilder routeGroup, AIAgent agent)
#pragma warning restore IDE0051 // Remove unused private members
{
var endpointAgentName = agent.DisplayName;
var conversationsProcessor = new AIAgentConversationsProcessor(agent);
var agent = serviceProvider.GetKeyedService<AIAgent>(agentName);
if (agent is null)
{
return Results.NotFound($"Agent named '{agentName}' was not found.");
}

routeGroup.MapGet("/{conversation_id}", (string conversationId, CancellationToken cancellationToken)
=> conversationsProcessor.GetConversationAsync(conversationId, cancellationToken)
).WithName(endpointAgentName + "/RetrieveConversation");
return await AIAgentResponsesProcessor.CreateModelResponseAsync(agent, createResponse, cancellationToken).ConfigureAwait(false);
}).WithName("CreateResponse");
return group;
}

private static void ValidateAgentName([NotNull] string agentName)
Expand Down
Loading
Loading