mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Update M.E.AI and delete a bunch of dead code (#676)
This commit is contained in:
committed by
GitHub
Unverified
parent
57d09afe04
commit
f9e133237d
@@ -11,34 +11,34 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="9.4.1-preview.1.25408.4" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="9.4.1" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="9.4.1" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CosmosDB" Version="9.4.1" />
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="9.4.1" />
|
||||
<PackageVersion Include="Aspire.Hosting.Testing" Version="9.4.1" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.3.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.14.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="9.6.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.8.0-preview.1.25412.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.8.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.1" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="9.4.2-preview.1.25428.12" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="9.4.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="9.4.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CosmosDB" Version="9.4.2" />
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="9.4.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.Testing" Version="9.4.2" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.3.0-beta.2" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.15.0" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="9.7.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.9.0-preview.1.25458.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.52.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.53.1" />
|
||||
<!-- Newtonsoft (Required by CosmosClient) -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="9.0.8" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.8" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="9.0.8" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="9.0.9" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.9" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.9" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="9.0.9" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -48,31 +48,31 @@
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.8.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.8.0-preview.1.25412.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.8.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Testing" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.9" />
|
||||
<PackageVersion Include="OpenAI" Version="2.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.0-preview.1.25458.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.9.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Testing" Version="9.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.9" />
|
||||
<!-- Vector Stores -->
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.64.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.64.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Core" Version="1.64.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.OpenAI" Version="1.64.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.64.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.65.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.65.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Core" Version="1.65.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.OpenAI" Version="1.65.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.65.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.1.151" />
|
||||
<!-- A2A -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<IsAotCompatible>false</IsAotCompatible>
|
||||
<ProjectsTargetFrameworks>net472;net9.0</ProjectsTargetFrameworks>
|
||||
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
|
||||
<NoWarn>$(NoWarn);CA1707</NoWarn>
|
||||
<NoWarn>$(NoWarn);CA1707;MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,7 +8,6 @@ using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using Microsoft.Shared.Samples;
|
||||
using OpenAI;
|
||||
using OpenAI.Assistants;
|
||||
using OpenAI.Chat;
|
||||
using OpenAI.Responses;
|
||||
@@ -111,13 +110,13 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output)
|
||||
|
||||
private IChatClient GetOpenAIResponsesClient()
|
||||
=> new OpenAIResponseClient(TestConfiguration.OpenAI.ChatModelId, TestConfiguration.OpenAI.ApiKey)
|
||||
.AsNewIChatClient();
|
||||
.AsIChatClient();
|
||||
|
||||
private IChatClient GetAzureAIAgentPersistentClient(ChatClientAgentOptions options)
|
||||
=> new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential()).AsNewIChatClient(options.Id!);
|
||||
|
||||
private IChatClient GetOpenAIAssistantChatClient(ChatClientAgentOptions options)
|
||||
=> new AssistantClient(TestConfiguration.OpenAI.ApiKey).AsNewIChatClient(options.Id!);
|
||||
=> new AssistantClient(TestConfiguration.OpenAI.ApiKey).AsIChatClient(options.Id!);
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
+2
-2
@@ -22,11 +22,11 @@ AIAgent agent = new OpenAIClient(apiKey)
|
||||
UserChatMessage chatMessage = new("Tell me a joke about a pirate.");
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
ChatCompletion chatCompletion = await agent.RunAsync(chatMessage);
|
||||
ChatCompletion chatCompletion = await agent.RunAsync([chatMessage]);
|
||||
Console.WriteLine(chatCompletion.Content.Last().Text);
|
||||
|
||||
// Invoke the agent with streaming support.
|
||||
AsyncCollectionResult<StreamingChatCompletionUpdate> completionUpdates = agent.RunStreamingAsync(chatMessage);
|
||||
AsyncCollectionResult<StreamingChatCompletionUpdate> completionUpdates = agent.RunStreamingAsync([chatMessage]);
|
||||
await foreach (StreamingChatCompletionUpdate completionUpdate in completionUpdates)
|
||||
{
|
||||
if (completionUpdate.ContentUpdate.Count > 0)
|
||||
|
||||
@@ -216,7 +216,7 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
|
||||
|
||||
static void Terminate()
|
||||
{
|
||||
if (NewFunctionInvokingChatClient.CurrentContext is not { } ctx)
|
||||
if (FunctionInvokingChatClient.CurrentContext is not { } ctx)
|
||||
{
|
||||
throw new NotSupportedException($"The agent is not configured with a {nameof(FunctionInvokingChatClient)}. Cease execution.");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.Orchestration</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request for user approval of a function call.
|
||||
/// </summary>
|
||||
public sealed class FunctionApprovalRequestContent : UserInputRequestContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FunctionApprovalRequestContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the function approval request/response pair.</param>
|
||||
/// <param name="functionCall">The function call that requires user approval.</param>
|
||||
public FunctionApprovalRequestContent(string id, FunctionCallContent functionCall)
|
||||
: base(id)
|
||||
{
|
||||
this.FunctionCall = Throw.IfNull(functionCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function call that pre-invoke approval is required for.
|
||||
/// </summary>
|
||||
public FunctionCallContent FunctionCall { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FunctionApprovalResponseContent"/> to indicate whether the function call is approved or rejected based on the value of <paramref name="approved"/>.
|
||||
/// </summary>
|
||||
/// <param name="approved"><see langword="true"/> if the function call is approved; otherwise, <see langword="false"/>.</param>
|
||||
/// <returns>The <see cref="FunctionApprovalResponseContent"/> representing the approval response.</returns>
|
||||
public FunctionApprovalResponseContent CreateResponse(bool approved)
|
||||
=> new(this.Id, approved, this.FunctionCall);
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response to a function approval request.
|
||||
/// </summary>
|
||||
public sealed class FunctionApprovalResponseContent : UserInputResponseContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FunctionApprovalResponseContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the function approval request/response pair.</param>
|
||||
/// <param name="approved">Indicates whether the request was approved.</param>
|
||||
/// <param name="functionCall">The function call that requires user approval.</param>
|
||||
public FunctionApprovalResponseContent(string id, bool approved, FunctionCallContent functionCall)
|
||||
: base(id)
|
||||
{
|
||||
this.Approved = approved;
|
||||
this.FunctionCall = Throw.IfNull(functionCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user approved the request.
|
||||
/// </summary>
|
||||
public bool Approved { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function call that pre-invoke approval is required for.
|
||||
/// </summary>
|
||||
public FunctionCallContent FunctionCall { get; }
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for user input request content.
|
||||
/// </summary>
|
||||
public abstract class UserInputRequestContent : AIContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserInputRequestContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the user input request/response pair.</param>
|
||||
protected UserInputRequestContent(string id)
|
||||
{
|
||||
Id = Throw.IfNullOrWhitespace(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID to uniquely identify the user input request/response pair.
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for user input response content.
|
||||
/// </summary>
|
||||
public abstract class UserInputResponseContent : AIContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserInputResponseContent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to uniquely identify the user input request/response pair.</param>
|
||||
protected UserInputResponseContent(string id)
|
||||
{
|
||||
Id = Throw.IfNullOrWhitespace(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID to uniquely identify the user input request/response pair.
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Extensions.AI.Agents</RootNamespace>
|
||||
<NoWarn>$(NoWarn);CA1716;IDE0009;</NoWarn>
|
||||
<NoWarn>$(NoWarn);CA1716;IDE0009;MEAI001</NoWarn>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
+2
-337
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Extensions.AI.Agents.OpenAI.ChatCompletion;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -22,33 +20,6 @@ namespace OpenAI;
|
||||
/// </remarks>
|
||||
public static class AIAgentWithOpenAIExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the AI agent with a single OpenAI chat message and returns the response as a native OpenAI <see cref="ChatCompletion"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The AI agent to run.</param>
|
||||
/// <param name="message">The OpenAI chat message to send to the agent.</param>
|
||||
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided message and agent response.</param>
|
||||
/// <param name="options">Optional parameters for agent invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="Task{ChatCompletion}"/> representing the asynchronous operation that returns a native OpenAI <see cref="ChatCompletion"/> response.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> or <paramref name="message"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to a <see cref="ChatCompletion"/>, typically when the underlying representation is not an OpenAI response.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when the <paramref name="message"/> type is not supported by the message conversion method.</exception>
|
||||
/// <remarks>
|
||||
/// This method converts the OpenAI chat message to the Microsoft Extensions AI format using the appropriate conversion method,
|
||||
/// runs the agent, and then extracts the native OpenAI <see cref="ChatCompletion"/> from the response using <see cref="AgentRunResponseExtensions.AsChatCompletion"/>.
|
||||
/// </remarks>
|
||||
public static async Task<ChatCompletion> RunAsync(this AIAgent agent, OpenAI.Chat.ChatMessage message, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
Throw.IfNull(message);
|
||||
|
||||
var response = await agent.RunAsync(message.AsChatMessage(), thread, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var chatCompletion = response.AsChatCompletion();
|
||||
return chatCompletion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the AI agent with a collection of OpenAI chat messages and returns the response as a native OpenAI <see cref="ChatCompletion"/>.
|
||||
/// </summary>
|
||||
@@ -62,7 +33,7 @@ public static class AIAgentWithOpenAIExtensions
|
||||
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to a <see cref="ChatCompletion"/>, typically when the underlying representation is not an OpenAI response.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when any message in <paramref name="messages"/> has a type that is not supported by the message conversion method.</exception>
|
||||
/// <remarks>
|
||||
/// This method converts each OpenAI chat message to the Microsoft Extensions AI format using <see cref="AsChatMessages"/>,
|
||||
/// This method converts the OpenAI chat messages to the Microsoft Extensions AI format using the appropriate conversion method,
|
||||
/// runs the agent with the converted message collection, and then extracts the native OpenAI <see cref="ChatCompletion"/> from the response using <see cref="AgentRunResponseExtensions.AsChatCompletion"/>.
|
||||
/// </remarks>
|
||||
public static async Task<ChatCompletion> RunAsync(this AIAgent agent, IEnumerable<OpenAI.Chat.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
@@ -76,32 +47,6 @@ public static class AIAgentWithOpenAIExtensions
|
||||
return chatCompletion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the AI agent with a single OpenAI chat message and returns the response as collection of native OpenAI <see cref="StreamingChatCompletionUpdate"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The AI agent to run.</param>
|
||||
/// <param name="message">The OpenAI chat message to send to the agent.</param>
|
||||
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided message and agent response.</param>
|
||||
/// <param name="options">Optional parameters for agent invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="Task{ChatCompletion}"/> representing the asynchronous operation that returns a native OpenAI <see cref="ChatCompletion"/> response.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> or <paramref name="message"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to a <see cref="ChatCompletion"/>, typically when the underlying representation is not an OpenAI response.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when the <paramref name="message"/> type is not supported by the message conversion method.</exception>
|
||||
/// <remarks>
|
||||
/// This method converts the OpenAI chat message to the Microsoft Extensions AI format using the appropriate conversion method,
|
||||
/// runs the agent, and then extracts the native OpenAI <see cref="ChatCompletion"/> from the response using <see cref="AgentRunResponseExtensions.AsChatCompletion"/>.
|
||||
/// </remarks>
|
||||
public static AsyncCollectionResult<StreamingChatCompletionUpdate> RunStreamingAsync(this AIAgent agent, OpenAI.Chat.ChatMessage message, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
Throw.IfNull(message);
|
||||
|
||||
IAsyncEnumerable<AgentRunResponseUpdate> response = agent.RunStreamingAsync(message.AsChatMessage(), thread, options, cancellationToken);
|
||||
|
||||
return new AsyncStreamingUpdateCollectionResult(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the AI agent with a single OpenAI chat message and returns the response as collection of native OpenAI <see cref="StreamingChatCompletionUpdate"/>.
|
||||
/// </summary>
|
||||
@@ -115,7 +60,7 @@ public static class AIAgentWithOpenAIExtensions
|
||||
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to a <see cref="ChatCompletion"/>, typically when the underlying representation is not an OpenAI response.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when the <paramref name="messages"/> type is not supported by the message conversion method.</exception>
|
||||
/// <remarks>
|
||||
/// This method converts the OpenAI chat message to the Microsoft Extensions AI format using the appropriate conversion method,
|
||||
/// This method converts the OpenAI chat messages to the Microsoft Extensions AI format using the appropriate conversion method,
|
||||
/// runs the agent, and then extracts the native OpenAI <see cref="ChatCompletion"/> from the response using <see cref="AgentRunResponseExtensions.AsChatCompletion"/>.
|
||||
/// </remarks>
|
||||
public static AsyncCollectionResult<StreamingChatCompletionUpdate> RunStreamingAsync(this AIAgent agent, IEnumerable<OpenAI.Chat.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
@@ -127,284 +72,4 @@ public static class AIAgentWithOpenAIExtensions
|
||||
|
||||
return new AsyncStreamingUpdateCollectionResult(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sequence of <see cref="Microsoft.Extensions.AI.ChatMessage"/> instances from the specified OpenAI input messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The OpenAI input messages to convert.</param>
|
||||
/// <returns>A sequence of Microsoft Extensions AI chat messages converted from the OpenAI messages.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when a message type is encountered that cannot be converted.</exception>
|
||||
/// <remarks>
|
||||
/// This method supports conversion of the following OpenAI message types:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="AssistantChatMessage"/></description></item>
|
||||
/// <item><description><see cref="DeveloperChatMessage"/></description></item>
|
||||
/// <item><description><see cref="FunctionChatMessage"/> (obsolete)</description></item>
|
||||
/// <item><description><see cref="SystemChatMessage"/></description></item>
|
||||
/// <item><description><see cref="ToolChatMessage"/></description></item>
|
||||
/// <item><description><see cref="UserChatMessage"/></description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static IEnumerable<Microsoft.Extensions.AI.ChatMessage> AsChatMessages(this IEnumerable<OpenAI.Chat.ChatMessage> messages)
|
||||
{
|
||||
Throw.IfNull(messages);
|
||||
|
||||
foreach (OpenAI.Chat.ChatMessage message in messages)
|
||||
{
|
||||
switch (message)
|
||||
{
|
||||
case OpenAI.Chat.AssistantChatMessage assistantMessage:
|
||||
yield return assistantMessage.AsChatMessage();
|
||||
break;
|
||||
case OpenAI.Chat.DeveloperChatMessage developerMessage:
|
||||
yield return developerMessage.AsChatMessage();
|
||||
break;
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
case OpenAI.Chat.FunctionChatMessage functionMessage:
|
||||
yield return functionMessage.AsChatMessage();
|
||||
break;
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
case OpenAI.Chat.SystemChatMessage systemMessage:
|
||||
yield return systemMessage.AsChatMessage();
|
||||
break;
|
||||
case OpenAI.Chat.ToolChatMessage toolMessage:
|
||||
yield return toolMessage.AsChatMessage();
|
||||
break;
|
||||
case OpenAI.Chat.UserChatMessage userMessage:
|
||||
yield return userMessage.AsChatMessage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenAI chat message to a Microsoft Extensions AI <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="chatMessage">The OpenAI chat message to convert.</param>
|
||||
/// <returns>A <see cref="Microsoft.Extensions.AI.ChatMessage"/> equivalent of the input OpenAI message.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="chatMessage"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when the <paramref name="chatMessage"/> type is not supported for conversion.</exception>
|
||||
/// <remarks>
|
||||
/// This method provides a bridge between OpenAI SDK message types and Microsoft Extensions AI message types.
|
||||
/// It handles the conversion by switching on the concrete type of the OpenAI message and calling the appropriate
|
||||
/// specialized conversion method.
|
||||
/// </remarks>
|
||||
internal static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this OpenAI.Chat.ChatMessage chatMessage)
|
||||
{
|
||||
Throw.IfNull(chatMessage);
|
||||
|
||||
return chatMessage switch
|
||||
{
|
||||
AssistantChatMessage assistantMessage => assistantMessage.AsChatMessage(),
|
||||
DeveloperChatMessage developerMessage => developerMessage.AsChatMessage(),
|
||||
SystemChatMessage systemMessage => systemMessage.AsChatMessage(),
|
||||
ToolChatMessage toolMessage => toolMessage.AsChatMessage(),
|
||||
UserChatMessage userMessage => userMessage.AsChatMessage(),
|
||||
_ => throw new NotSupportedException($"Message type {chatMessage.GetType().Name} is not supported for conversion.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts OpenAI chat message content to Microsoft Extensions AI content items.
|
||||
/// </summary>
|
||||
/// <param name="content">The OpenAI chat message content to convert.</param>
|
||||
/// <returns>A sequence of <see cref="AIContent"/> items converted from the OpenAI content.</returns>
|
||||
/// <remarks>
|
||||
/// This method supports conversion of the following OpenAI content part types:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Text content (converted to <see cref="TextContent"/>)</description></item>
|
||||
/// <item><description>Refusal content (converted to <see cref="TextContent"/>)</description></item>
|
||||
/// <item><description>Image content (converted to <see cref="DataContent"/> or <see cref="UriContent"/>)</description></item>
|
||||
/// <item><description>Input audio content (converted to <see cref="DataContent"/>)</description></item>
|
||||
/// <item><description>File content (converted to <see cref="DataContent"/>)</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
private static IEnumerable<AIContent> AsAIContent(this OpenAI.Chat.ChatMessageContent content)
|
||||
{
|
||||
Throw.IfNull(content);
|
||||
|
||||
foreach (OpenAI.Chat.ChatMessageContentPart part in content)
|
||||
{
|
||||
switch (part.Kind)
|
||||
{
|
||||
case OpenAI.Chat.ChatMessageContentPartKind.Text:
|
||||
yield return new TextContent(part.Text)
|
||||
{
|
||||
RawRepresentation = content
|
||||
};
|
||||
break;
|
||||
case OpenAI.Chat.ChatMessageContentPartKind.Refusal:
|
||||
yield return new TextContent(part.Refusal)
|
||||
{
|
||||
RawRepresentation = content
|
||||
};
|
||||
break;
|
||||
case OpenAI.Chat.ChatMessageContentPartKind.Image:
|
||||
if (part.ImageBytes is not null)
|
||||
{
|
||||
yield return new DataContent(part.ImageBytes, part.ImageBytesMediaType)
|
||||
{
|
||||
RawRepresentation = content
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new UriContent(part.ImageUri, "image/*")
|
||||
{
|
||||
RawRepresentation = content
|
||||
};
|
||||
}
|
||||
break;
|
||||
case OpenAI.Chat.ChatMessageContentPartKind.InputAudio:
|
||||
yield return new DataContent(part.InputAudioBytes, "audio/*")
|
||||
{
|
||||
RawRepresentation = content
|
||||
};
|
||||
break;
|
||||
case OpenAI.Chat.ChatMessageContentPartKind.File:
|
||||
yield return new DataContent(part.FileBytes, part.FileBytesMediaType)
|
||||
{
|
||||
RawRepresentation = content
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Content part kind '{part.Kind}' is not supported for conversion to AIContent.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts OpenAI chat message content to text.
|
||||
/// </summary>
|
||||
/// <param name="content">The OpenAI chat message content to convert.</param>
|
||||
/// <returns>A string created from the text and refusal parts of the OpenAI content.</returns>
|
||||
/// <remarks>
|
||||
/// Using when converting OpenAI For <c>tool</c> messages, the contents can only be of type <c>text</c>.
|
||||
/// </remarks>
|
||||
private static string AsText(this OpenAI.Chat.ChatMessageContent content)
|
||||
{
|
||||
Throw.IfNull(content);
|
||||
|
||||
StringBuilder text = new();
|
||||
foreach (OpenAI.Chat.ChatMessageContentPart part in content)
|
||||
{
|
||||
switch (part.Kind)
|
||||
{
|
||||
case OpenAI.Chat.ChatMessageContentPartKind.Text:
|
||||
text.Append(part.Text);
|
||||
break;
|
||||
case OpenAI.Chat.ChatMessageContentPartKind.Refusal:
|
||||
text.Append(part.Refusal);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Content part kind '{part.Kind}' is not supported for conversion to text.");
|
||||
}
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenAI <see cref="AssistantChatMessage"/> to a Microsoft Extensions AI <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="assistantMessage">The OpenAI assistant message to convert.</param>
|
||||
/// <returns>A Microsoft Extensions AI chat message with assistant role.</returns>
|
||||
/// <remarks>
|
||||
/// This method converts the assistant message content using <see cref="AsAIContent"/> and preserves
|
||||
/// the participant name as the author name in the resulting message.
|
||||
/// </remarks>
|
||||
private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this AssistantChatMessage assistantMessage)
|
||||
{
|
||||
Throw.IfNull(assistantMessage);
|
||||
|
||||
return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, [.. assistantMessage.Content.AsAIContent()])
|
||||
{
|
||||
AuthorName = assistantMessage.ParticipantName,
|
||||
RawRepresentation = assistantMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenAI <see cref="DeveloperChatMessage"/> to a Microsoft Extensions AI <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="developerMessage">The OpenAI developer message to convert.</param>
|
||||
/// <returns>A Microsoft Extensions AI chat message with system role.</returns>
|
||||
/// <remarks>
|
||||
/// Developer messages are treated as system messages in the Microsoft Extensions AI framework.
|
||||
/// The participant name is preserved as the author name.
|
||||
/// </remarks>
|
||||
private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this DeveloperChatMessage developerMessage)
|
||||
{
|
||||
Throw.IfNull(developerMessage);
|
||||
|
||||
return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.System, [.. developerMessage.Content.AsAIContent()])
|
||||
{
|
||||
AuthorName = developerMessage.ParticipantName,
|
||||
RawRepresentation = developerMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenAI <see cref="SystemChatMessage"/> to a Microsoft Extensions AI <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="systemMessage">The OpenAI system message to convert.</param>
|
||||
/// <returns>A Microsoft Extensions AI chat message with system role.</returns>
|
||||
/// <remarks>
|
||||
/// This method converts the system message content using <see cref="AsAIContent"/> and preserves
|
||||
/// the participant name as the author name in the resulting message.
|
||||
/// </remarks>
|
||||
private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this SystemChatMessage systemMessage)
|
||||
{
|
||||
Throw.IfNull(systemMessage);
|
||||
|
||||
return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.System, [.. systemMessage.Content.AsAIContent()])
|
||||
{
|
||||
AuthorName = systemMessage.ParticipantName,
|
||||
RawRepresentation = systemMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenAI <see cref="ToolChatMessage"/> to a Microsoft Extensions AI <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="toolMessage">The OpenAI tool message to convert.</param>
|
||||
/// <returns>A Microsoft Extensions AI chat message with tool role.</returns>
|
||||
/// <remarks>
|
||||
/// This method converts tool message content using <see cref="AsAIContent"/> and includes the tool call ID
|
||||
/// in the resulting message's additional properties for traceability.
|
||||
/// </remarks>
|
||||
private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this ToolChatMessage toolMessage)
|
||||
{
|
||||
Throw.IfNull(toolMessage);
|
||||
|
||||
var content = new FunctionResultContent(toolMessage.ToolCallId, toolMessage.Content.AsText())
|
||||
{
|
||||
RawRepresentation = toolMessage
|
||||
};
|
||||
return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Tool, [content])
|
||||
{
|
||||
RawRepresentation = toolMessage,
|
||||
AdditionalProperties = new() { ["tool_call_id"] = toolMessage.ToolCallId }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenAI <see cref="UserChatMessage"/> to a Microsoft Extensions AI <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="userMessage">The OpenAI user message to convert.</param>
|
||||
/// <returns>A Microsoft Extensions AI chat message with user role.</returns>
|
||||
/// <remarks>
|
||||
/// This method converts the user message content using <see cref="AsAIContent"/> and preserves
|
||||
/// the participant name as the author name in the resulting message.
|
||||
/// </remarks>
|
||||
private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this UserChatMessage userMessage)
|
||||
{
|
||||
Throw.IfNull(userMessage);
|
||||
|
||||
return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, [.. userMessage.Content.AsAIContent()])
|
||||
{
|
||||
AuthorName = userMessage.ParticipantName,
|
||||
RawRepresentation = userMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="ChatOptions"/>
|
||||
/// </summary>
|
||||
public static class ChatOptionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Disables the storage of response output in the chat options.
|
||||
/// </summary>
|
||||
/// <param name="options">Instance of <see cref="ChatOptions"/></param>
|
||||
/// <exception cref="ArgumentNullException"></exception>
|
||||
public static ChatOptions WithResponseStoredOutputDisabled(this ChatOptions options)
|
||||
{
|
||||
Throw.IfNull(options);
|
||||
|
||||
// We can use the RawRepresentationFactory to provide Response service specific
|
||||
// options. Here we can indicate that we do not want the service to store the
|
||||
// conversation in a service managed thread.
|
||||
options.RawRepresentationFactory = (_) => new ResponseCreationOptions() { StoredOutputEnabled = false };
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
+1
-142
@@ -1,9 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -24,9 +20,6 @@ namespace OpenAI;
|
||||
/// </remarks>
|
||||
public static class OpenAIAssistantClientExtensions
|
||||
{
|
||||
/// <summary>Key into AdditionalProperties used to store a strict option.</summary>
|
||||
private const string StrictKey = "strictJsonSchema";
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AI agent from an <see cref="AssistantClient"/> using the OpenAI Assistant API.
|
||||
/// </summary>
|
||||
@@ -209,140 +202,6 @@ public static class OpenAIAssistantClientExtensions
|
||||
}
|
||||
};
|
||||
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
var chatClient = client.AsNewIChatClient(assistantId);
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
return new ChatClientAgent(chatClient, agentOptions, loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>Converts an Extensions function to an OpenAI assistants function tool.</summary>
|
||||
private static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null)
|
||||
{
|
||||
bool? strict =
|
||||
HasStrict(aiFunction.AdditionalProperties) ??
|
||||
HasStrict(options?.AdditionalProperties);
|
||||
|
||||
return new FunctionToolDefinition(aiFunction.Name)
|
||||
{
|
||||
Description = aiFunction.Description,
|
||||
Parameters = ToOpenAIFunctionParameters(aiFunction, strict),
|
||||
StrictParameterSchemaEnabled = strict,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Extracts from an <see cref="AIFunction"/> the parameters and strictness setting for use with OpenAI's APIs.</summary>
|
||||
private static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict)
|
||||
{
|
||||
// Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting.
|
||||
JsonElement jsonSchema = strict is true ?
|
||||
StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
|
||||
aiFunction.JsonSchema;
|
||||
|
||||
// Roundtrip the schema through the ToolJson model type to remove extra properties
|
||||
// and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData.
|
||||
var tool = jsonSchema.Deserialize(OpenAIJsonContext.Default.ToolJson)!;
|
||||
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson));
|
||||
|
||||
return functionParameters;
|
||||
}
|
||||
|
||||
/// <summary>Gets whether the properties specify that strict schema handling is desired.</summary>
|
||||
private static bool? HasStrict(IReadOnlyDictionary<string, object?>? additionalProperties) =>
|
||||
additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true &&
|
||||
strictObj is bool strictValue ?
|
||||
strictValue : null;
|
||||
|
||||
private static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
|
||||
{
|
||||
DisallowAdditionalProperties = true,
|
||||
ConvertBooleanSchemas = true,
|
||||
MoveDefaultKeywordToDescription = true,
|
||||
RequireAllProperties = true,
|
||||
TransformSchemaNode = (ctx, node) =>
|
||||
{
|
||||
// Move content from common but unsupported properties to description. In particular, we focus on properties that
|
||||
// the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation.
|
||||
|
||||
if (node is JsonObject schemaObj)
|
||||
{
|
||||
StringBuilder? additionalDescription = null;
|
||||
|
||||
ReadOnlySpan<string> unsupportedProperties =
|
||||
[
|
||||
// Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties:
|
||||
"contentEncoding", "contentMediaType", "not",
|
||||
|
||||
// Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models:
|
||||
"minLength", "maxLength", "pattern", "format",
|
||||
"minimum", "maximum", "multipleOf",
|
||||
"patternProperties",
|
||||
"minItems", "maxItems",
|
||||
|
||||
// Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords
|
||||
// as being unsupported with Azure OpenAI:
|
||||
"unevaluatedProperties", "propertyNames", "minProperties", "maxProperties",
|
||||
"unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems",
|
||||
];
|
||||
|
||||
foreach (string propName in unsupportedProperties)
|
||||
{
|
||||
if (schemaObj[propName] is { } propNode)
|
||||
{
|
||||
_ = schemaObj.Remove(propName);
|
||||
AppendLine(ref additionalDescription, propName, propNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalDescription is not null)
|
||||
{
|
||||
schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ?
|
||||
$"{descriptionNode.GetValue<string>()}{Environment.NewLine}{additionalDescription}" :
|
||||
additionalDescription.ToString();
|
||||
}
|
||||
|
||||
return node;
|
||||
|
||||
static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode)
|
||||
{
|
||||
sb ??= new();
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
_ = sb.AppendLine();
|
||||
}
|
||||
|
||||
_ = sb.Append(propName).Append(": ").Append(propNode);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
});
|
||||
|
||||
/// <summary>Used to create the JSON payload for an OpenAI tool description.</summary>
|
||||
internal sealed class ToolJson
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "object";
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public HashSet<string> Required { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public Dictionary<string, JsonElement> Properties { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("additionalProperties")]
|
||||
public bool AdditionalProperties { get; set; }
|
||||
return new ChatClientAgent(client.AsIChatClient(assistantId), agentOptions, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Source-generated JSON type information for use by all OpenAI implementations.</summary>
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true)]
|
||||
[JsonSerializable(typeof(OpenAIAssistantClientExtensions.ToolJson))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
internal sealed partial class OpenAIJsonContext : JsonSerializerContext;
|
||||
|
||||
+3
-12
@@ -33,6 +33,8 @@ public static class OpenAIResponseClientExtensions
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
|
||||
public static AIAgent CreateAIAgent(this OpenAIResponseClient client, string? instructions = null, string? name = null, string? description = null, IList<AITool>? tools = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.CreateAIAgent(
|
||||
new ChatClientAgentOptions()
|
||||
{
|
||||
@@ -60,17 +62,6 @@ public static class OpenAIResponseClientExtensions
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNull(options);
|
||||
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
var chatClient = client.AsNewIChatClient();
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
ChatClientAgent agent = new(chatClient, options, loggerFactory);
|
||||
return agent;
|
||||
return new ChatClientAgent(client.AsIChatClient(), options, loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="OpenAIResponseClient"/>.</summary>
|
||||
/// <param name="responseClient">The client.</param>
|
||||
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="OpenAIResponseClient"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
|
||||
public static IChatClient AsNewIChatClient(this OpenAIResponseClient responseClient) =>
|
||||
new NewOpenAIResponsesChatClient(responseClient);
|
||||
}
|
||||
|
||||
@@ -1,722 +0,0 @@
|
||||
#pragma warning disable IDE0005 // Using directive is unnecessary.
|
||||
#pragma warning disable IDE0073 // The file header does not match the required text
|
||||
#pragma warning disable CS0436 // Type conflicts with imported type
|
||||
#pragma warning disable CA1063 // Implement IDisposable Correctly
|
||||
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI;
|
||||
using OpenAI.Assistants;
|
||||
|
||||
#pragma warning disable CA1031 // Do not catch general exception types
|
||||
#pragma warning disable SA1005 // Single line comments should begin with single space
|
||||
#pragma warning disable SA1204 // Static elements should appear before instance elements
|
||||
#pragma warning disable S125 // Sections of code should not be commented out
|
||||
#pragma warning disable S907 // "goto" statement should not be used
|
||||
#pragma warning disable S1067 // Expressions should not be too complex
|
||||
#pragma warning disable S1751 // Loops with at most one iteration should be refactored
|
||||
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
|
||||
#pragma warning disable S4456 // Parameter validation in yielding methods should be wrapped
|
||||
#pragma warning disable S4457 // Parameter validation in "async"/"await" methods should be wrapped
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>Represents an <see cref="IChatClient"/> for an OpenAI <see cref="AssistantClient"/>.</summary>
|
||||
internal sealed class NewOpenAIAssistantsChatClient : IChatClient
|
||||
{
|
||||
/// <summary>The underlying <see cref="AssistantClient" />.</summary>
|
||||
private readonly AssistantClient _client;
|
||||
|
||||
/// <summary>Metadata for the client.</summary>
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
|
||||
/// <summary>The ID of the agent to use.</summary>
|
||||
private readonly string _assistantId;
|
||||
|
||||
/// <summary>The thread ID to use if none is supplied in <see cref="ChatOptions.ConversationId"/>.</summary>
|
||||
private readonly string? _defaultThreadId;
|
||||
|
||||
/// <summary>List of tools associated with the assistant.</summary>
|
||||
private IReadOnlyList<ToolDefinition>? _assistantTools;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpenAIAssistantsChatClient"/> class for the specified <see cref="AssistantClient"/>.</summary>
|
||||
public NewOpenAIAssistantsChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId)
|
||||
{
|
||||
_client = Throw.IfNull(assistantClient);
|
||||
_assistantId = Throw.IfNullOrWhitespace(assistantId);
|
||||
|
||||
_defaultThreadId = defaultThreadId;
|
||||
|
||||
// https://github.com/openai/openai-dotnet/issues/215
|
||||
// The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages
|
||||
// implement the abstractions directly rather than providing adapters on top of the public APIs,
|
||||
// the package can provide such implementations separate from what's exposed in the public API.
|
||||
Uri providerUrl = typeof(AssistantClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?.GetValue(assistantClient) as Uri ?? OpenAIClientExtensions2.DefaultOpenAIEndpoint;
|
||||
|
||||
_metadata = new("openai", providerUrl);
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpenAIAssistantsChatClient"/> class for the specified <see cref="AssistantClient"/>.</summary>
|
||||
public NewOpenAIAssistantsChatClient(AssistantClient assistantClient, Assistant assistant, string? defaultThreadId)
|
||||
: this(assistantClient, Throw.IfNull(assistant).Id, defaultThreadId)
|
||||
{
|
||||
_assistantTools = assistant.Tools;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) :
|
||||
serviceKey is not null ? null :
|
||||
serviceType == typeof(ChatClientMetadata) ? _metadata :
|
||||
serviceType == typeof(AssistantClient) ? _client :
|
||||
serviceType.IsInstanceOfType(this) ? this :
|
||||
null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Changing the original implementation to provide a RawRepresentation as a list of RawRepresentations of the updates.
|
||||
// This wouldn't be needed if the API Change Proposal below is accepted:
|
||||
// https://github.com/dotnet/extensions/issues/6746
|
||||
var updates = await GetStreamingResponseAsync(messages, options, cancellationToken).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var response = updates.ToChatResponse();
|
||||
|
||||
// Expose all the raw representations of the updates.
|
||||
response.RawRepresentation = updates.Select(u => u.RawRepresentation).ToArray();
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
|
||||
// Extract necessary state from messages and options.
|
||||
(RunCreationOptions runOptions, ToolResources? toolResources, List<FunctionResultContent>? toolResults) = await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get the thread ID.
|
||||
string? threadId = options?.ConversationId ?? _defaultThreadId;
|
||||
if (threadId is null && toolResults is not null)
|
||||
{
|
||||
Throw.ArgumentException(nameof(messages), "No thread ID was provided, but chat messages includes tool results.");
|
||||
}
|
||||
|
||||
// Get any active run ID for this thread. This is necessary in case a thread has been left with an
|
||||
// active run, in which all attempts other than submitting tools will fail. We thus need to cancel
|
||||
// any active run on the thread.
|
||||
ThreadRun? threadRun = null;
|
||||
if (threadId is not null)
|
||||
{
|
||||
await foreach (var run in _client.GetRunsAsync(
|
||||
threadId,
|
||||
new RunCollectionOptions { Order = RunCollectionOrder.Descending, PageSizeLimit = 1 },
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (run.Status != RunStatus.Completed && run.Status != RunStatus.Cancelled && run.Status != RunStatus.Failed && run.Status != RunStatus.Expired)
|
||||
{
|
||||
threadRun = run;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit the request.
|
||||
IAsyncEnumerable<StreamingUpdate> updates;
|
||||
if (threadRun is not null &&
|
||||
ConvertFunctionResultsToToolOutput(toolResults, out List<ToolOutput>? toolOutputs) is { } toolRunId &&
|
||||
toolRunId == threadRun.Id)
|
||||
{
|
||||
// There's an active run and we have tool results to submit, so submit the results and continue streaming.
|
||||
// This is going to ignore any additional messages in the run options, as we are only submitting tool outputs,
|
||||
// but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare.
|
||||
updates = _client.SubmitToolOutputsToRunStreamingAsync(threadRun.ThreadId, threadRun.Id, toolOutputs, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (threadId is null)
|
||||
{
|
||||
// No thread ID was provided, so create a new thread.
|
||||
ThreadCreationOptions threadCreationOptions = new()
|
||||
{
|
||||
ToolResources = toolResources,
|
||||
};
|
||||
|
||||
foreach (var message in runOptions.AdditionalMessages)
|
||||
{
|
||||
threadCreationOptions.InitialMessages.Add(message);
|
||||
}
|
||||
|
||||
runOptions.AdditionalMessages.Clear();
|
||||
|
||||
var thread = await _client.CreateThreadAsync(threadCreationOptions, cancellationToken).ConfigureAwait(false);
|
||||
threadId = thread.Value.Id;
|
||||
}
|
||||
else if (threadRun is not null)
|
||||
{
|
||||
// There was an active run; we need to cancel it before starting a new run.
|
||||
_ = await _client.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false);
|
||||
threadRun = null;
|
||||
}
|
||||
|
||||
// Now create a new run and stream the results.
|
||||
updates = _client.CreateRunStreamingAsync(
|
||||
threadId: threadId,
|
||||
_assistantId,
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Process each update.
|
||||
string? responseId = null;
|
||||
await foreach (var update in updates.ConfigureAwait(false))
|
||||
{
|
||||
switch (update)
|
||||
{
|
||||
case ThreadUpdate tu:
|
||||
threadId ??= tu.Value.Id;
|
||||
goto default;
|
||||
|
||||
case RunUpdate ru:
|
||||
threadId ??= ru.Value.ThreadId;
|
||||
responseId ??= ru.Value.Id;
|
||||
|
||||
ChatResponseUpdate ruUpdate = new()
|
||||
{
|
||||
AuthorName = _assistantId,
|
||||
ConversationId = threadId,
|
||||
CreatedAt = ru.Value.CreatedAt,
|
||||
MessageId = responseId,
|
||||
ModelId = ru.Value.Model,
|
||||
RawRepresentation = ru,
|
||||
ResponseId = responseId,
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
|
||||
if (ru.Value.Usage is { } usage)
|
||||
{
|
||||
ruUpdate.Contents.Add(new UsageContent(new()
|
||||
{
|
||||
InputTokenCount = usage.InputTokenCount,
|
||||
OutputTokenCount = usage.OutputTokenCount,
|
||||
TotalTokenCount = usage.TotalTokenCount,
|
||||
}));
|
||||
}
|
||||
|
||||
if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName)
|
||||
{
|
||||
var fcc = OpenAIClientExtensions2.ParseCallContent(
|
||||
rau.FunctionArguments,
|
||||
JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray),
|
||||
functionName);
|
||||
fcc.RawRepresentation = ru;
|
||||
ruUpdate.Contents.Add(fcc);
|
||||
}
|
||||
|
||||
yield return ruUpdate;
|
||||
break;
|
||||
|
||||
case MessageContentUpdate mcu:
|
||||
ChatResponseUpdate textUpdate = new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text)
|
||||
{
|
||||
AuthorName = _assistantId,
|
||||
ConversationId = threadId,
|
||||
MessageId = responseId,
|
||||
RawRepresentation = mcu,
|
||||
ResponseId = responseId,
|
||||
};
|
||||
|
||||
// Add any annotations from the text update. The OpenAI Assistants API does not support passing these back
|
||||
// into the model (MessageContent.FromXx does not support providing annotations), so they end up being one way and are dropped
|
||||
// on subsequent requests.
|
||||
if (mcu.TextAnnotation is { } tau)
|
||||
{
|
||||
string? fileId = null;
|
||||
string? toolName = null;
|
||||
if (!string.IsNullOrWhiteSpace(tau.InputFileId))
|
||||
{
|
||||
fileId = tau.InputFileId;
|
||||
toolName = "file_search";
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(tau.OutputFileId))
|
||||
{
|
||||
fileId = tau.OutputFileId;
|
||||
toolName = "code_interpreter";
|
||||
}
|
||||
|
||||
if (fileId is not null)
|
||||
{
|
||||
if (textUpdate.Contents.Count == 0)
|
||||
{
|
||||
// In case a chunk doesn't have text content, create one with empty text to hold the annotation.
|
||||
textUpdate.Contents.Add(new TextContent(string.Empty));
|
||||
}
|
||||
|
||||
(((TextContent)textUpdate.Contents[0]).Annotations ??= []).Add(new CitationAnnotation
|
||||
{
|
||||
RawRepresentation = tau,
|
||||
AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = tau.StartIndex, EndIndex = tau.EndIndex }],
|
||||
FileId = fileId,
|
||||
ToolName = toolName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield return textUpdate;
|
||||
break;
|
||||
|
||||
default:
|
||||
yield return new()
|
||||
{
|
||||
AuthorName = _assistantId,
|
||||
ConversationId = threadId,
|
||||
MessageId = responseId,
|
||||
RawRepresentation = update,
|
||||
ResponseId = responseId,
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// nop
|
||||
}
|
||||
|
||||
/// <summary>Converts an Extensions function to an OpenAI assistants function tool.</summary>
|
||||
internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null)
|
||||
{
|
||||
bool? strict =
|
||||
OpenAIClientExtensions2.HasStrict(aiFunction.AdditionalProperties) ??
|
||||
OpenAIClientExtensions2.HasStrict(options?.AdditionalProperties);
|
||||
|
||||
return new FunctionToolDefinition(aiFunction.Name)
|
||||
{
|
||||
Description = aiFunction.Description,
|
||||
Parameters = OpenAIClientExtensions2.ToOpenAIFunctionParameters(aiFunction, strict),
|
||||
StrictParameterSchemaEnabled = strict,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the <see cref="RunCreationOptions"/> to use for the request and extracts any function result contents
|
||||
/// that need to be submitted as tool results.
|
||||
/// </summary>
|
||||
private async ValueTask<(RunCreationOptions RunOptions, ToolResources? Resources, List<FunctionResultContent>? ToolResults)> CreateRunOptionsAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellationToken)
|
||||
{
|
||||
// Create the options instance to populate, either a fresh or using one the caller provides.
|
||||
RunCreationOptions runOptions =
|
||||
options?.RawRepresentationFactory?.Invoke(this) as RunCreationOptions ??
|
||||
new();
|
||||
|
||||
ToolResources? resources = null;
|
||||
|
||||
// Populate the run options from the ChatOptions, if provided.
|
||||
if (options is not null)
|
||||
{
|
||||
runOptions.MaxOutputTokenCount ??= options.MaxOutputTokens;
|
||||
runOptions.ModelOverride ??= options.ModelId;
|
||||
runOptions.NucleusSamplingFactor ??= options.TopP;
|
||||
runOptions.Temperature ??= options.Temperature;
|
||||
runOptions.AllowParallelToolCalls ??= options.AllowMultipleToolCalls;
|
||||
|
||||
if (options.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
// If the caller has provided any tool overrides, we'll assume they don't want to use the assistant's tools.
|
||||
// But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to
|
||||
// just add them. To handle that, we'll get all of the assistant's tools and add them to the override list
|
||||
// along with our tools.
|
||||
if (runOptions.ToolsOverride.Count == 0)
|
||||
{
|
||||
if (_assistantTools is null)
|
||||
{
|
||||
var assistant = await _client.GetAssistantAsync(_assistantId, cancellationToken).ConfigureAwait(false);
|
||||
_assistantTools = assistant.Value.Tools;
|
||||
}
|
||||
|
||||
foreach (var tool in _assistantTools)
|
||||
{
|
||||
runOptions.ToolsOverride.Add(tool);
|
||||
}
|
||||
}
|
||||
|
||||
// The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools.
|
||||
foreach (AITool tool in tools)
|
||||
{
|
||||
switch (tool)
|
||||
{
|
||||
case AIFunction aiFunction:
|
||||
runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options));
|
||||
break;
|
||||
|
||||
case HostedCodeInterpreterTool codeInterpreterTool:
|
||||
var interpreterToolDef = ToolDefinition.CreateCodeInterpreter();
|
||||
runOptions.ToolsOverride.Add(interpreterToolDef);
|
||||
|
||||
if (codeInterpreterTool.Inputs?.Count is > 0)
|
||||
{
|
||||
ThreadInitializationMessage? threadInitializationMessage = null;
|
||||
foreach (var input in codeInterpreterTool.Inputs)
|
||||
{
|
||||
if (input is HostedFileContent hostedFile)
|
||||
{
|
||||
threadInitializationMessage ??= new(MessageRole.User, [MessageContent.FromText("attachments")]);
|
||||
threadInitializationMessage.Attachments.Add(new(hostedFile.FileId, [interpreterToolDef]));
|
||||
}
|
||||
}
|
||||
|
||||
if (threadInitializationMessage is not null)
|
||||
{
|
||||
runOptions.AdditionalMessages.Add(threadInitializationMessage);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case HostedFileSearchTool fileSearchTool:
|
||||
runOptions.ToolsOverride.Add(ToolDefinition.CreateFileSearch(fileSearchTool.MaximumResultCount));
|
||||
if (fileSearchTool.Inputs is { Count: > 0 } fileSearchInputs)
|
||||
{
|
||||
foreach (var input in fileSearchInputs)
|
||||
{
|
||||
if (input is HostedVectorStoreContent file)
|
||||
{
|
||||
(resources ??= new()).FileSearch ??= new();
|
||||
resources.FileSearch.VectorStoreIds.Add(file.VectorStoreId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the tool mode, if relevant.
|
||||
if (runOptions.ToolConstraint is null)
|
||||
{
|
||||
switch (options.ToolMode)
|
||||
{
|
||||
case NoneChatToolMode:
|
||||
runOptions.ToolConstraint = ToolConstraint.None;
|
||||
break;
|
||||
|
||||
case AutoChatToolMode:
|
||||
runOptions.ToolConstraint = ToolConstraint.Auto;
|
||||
break;
|
||||
|
||||
case RequiredChatToolMode required when required.RequiredFunctionName is { } functionName:
|
||||
runOptions.ToolConstraint = new ToolConstraint(ToolDefinition.CreateFunction(functionName));
|
||||
break;
|
||||
|
||||
case RequiredChatToolMode required:
|
||||
runOptions.ToolConstraint = ToolConstraint.Required;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the response format, if relevant.
|
||||
if (runOptions.ResponseFormat is null)
|
||||
{
|
||||
switch (options.ResponseFormat)
|
||||
{
|
||||
case ChatResponseFormatText:
|
||||
runOptions.ResponseFormat = AssistantResponseFormat.CreateTextFormat();
|
||||
break;
|
||||
|
||||
case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions2.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema:
|
||||
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat(
|
||||
jsonFormat.SchemaName,
|
||||
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)),
|
||||
jsonFormat.SchemaDescription,
|
||||
OpenAIClientExtensions2.HasStrict(options.AdditionalProperties));
|
||||
break;
|
||||
|
||||
case ChatResponseFormatJson jsonFormat:
|
||||
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonObjectFormat();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure system instructions.
|
||||
StringBuilder? instructions = null;
|
||||
void AppendSystemInstructions(string? toAppend)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(toAppend))
|
||||
{
|
||||
if (instructions is null)
|
||||
{
|
||||
instructions = new(toAppend);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = instructions.AppendLine().AppendLine(toAppend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppendSystemInstructions(runOptions.AdditionalInstructions);
|
||||
AppendSystemInstructions(options?.Instructions);
|
||||
|
||||
// Process ChatMessages.
|
||||
List<FunctionResultContent>? functionResults = null;
|
||||
foreach (var chatMessage in messages)
|
||||
{
|
||||
List<MessageContent> messageContents = [];
|
||||
|
||||
// Assistants doesn't support system/developer messages directly. It does support transient per-request instructions,
|
||||
// so we can use the system/developer messages to build up a set of instructions that will be passed to the assistant
|
||||
// as part of this request. However, in doing so, on a subsequent request that information will be lost, as there's no
|
||||
// way to store per-thread instructions in the OpenAI Assistants API. We don't want to convert these to user messages,
|
||||
// however, as that would then expose the system/developer messages in a way that might make the model more likely
|
||||
// to include that information in its responses. System messages should ideally be instead done as instructions to
|
||||
// the assistant when the assistant is created.
|
||||
if (chatMessage.Role == ChatRole.System ||
|
||||
chatMessage.Role == OpenAIClientExtensions2.ChatRoleDeveloper)
|
||||
{
|
||||
foreach (var textContent in chatMessage.Contents.OfType<TextContent>())
|
||||
{
|
||||
AppendSystemInstructions(textContent.Text);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (AIContent content in chatMessage.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case AIContent when content.RawRepresentation is MessageContent rawRep:
|
||||
messageContents.Add(rawRep);
|
||||
break;
|
||||
|
||||
case TextContent text:
|
||||
messageContents.Add(MessageContent.FromText(text.Text));
|
||||
break;
|
||||
|
||||
case UriContent image when image.HasTopLevelMediaType("image"):
|
||||
messageContents.Add(MessageContent.FromImageUri(image.Uri));
|
||||
break;
|
||||
|
||||
case FunctionResultContent result:
|
||||
(functionResults ??= []).Add(result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (messageContents.Count > 0)
|
||||
{
|
||||
runOptions.AdditionalMessages.Add(new ThreadInitializationMessage(
|
||||
chatMessage.Role == ChatRole.Assistant ? MessageRole.Assistant : MessageRole.User,
|
||||
messageContents));
|
||||
}
|
||||
}
|
||||
|
||||
runOptions.AdditionalInstructions = instructions?.ToString();
|
||||
|
||||
return (runOptions, resources, functionResults);
|
||||
}
|
||||
|
||||
/// <summary>Convert <see cref="FunctionResultContent"/> instances to <see cref="ToolOutput"/> instances.</summary>
|
||||
/// <param name="toolResults">The tool results to process.</param>
|
||||
/// <param name="toolOutputs">The generated list of tool outputs, if any could be created.</param>
|
||||
/// <returns>The run ID associated with the corresponding function call requests.</returns>
|
||||
private static string? ConvertFunctionResultsToToolOutput(List<FunctionResultContent>? toolResults, out List<ToolOutput>? toolOutputs)
|
||||
{
|
||||
string? runId = null;
|
||||
toolOutputs = null;
|
||||
if (toolResults?.Count > 0)
|
||||
{
|
||||
foreach (var frc in toolResults)
|
||||
{
|
||||
// When creating the FunctionCallContext, we created it with a CallId == [runId, callId].
|
||||
// We need to extract the run ID and ensure that the ToolOutput we send back to Azure
|
||||
// is only the call ID.
|
||||
string[]? runAndCallIDs;
|
||||
try
|
||||
{
|
||||
runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (runAndCallIDs is null ||
|
||||
runAndCallIDs.Length != 2 ||
|
||||
string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID
|
||||
string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID
|
||||
(runId is not null && runId != runAndCallIDs[0]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
runId = runAndCallIDs[0];
|
||||
(toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
return runId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Provides extension methods for working with <see cref="OpenAIClient"/>s.</summary>
|
||||
internal static class OpenAIClientExtensions2
|
||||
{
|
||||
/// <summary>Key into AdditionalProperties used to store a strict option.</summary>
|
||||
private const string StrictKey = "strictJsonSchema";
|
||||
|
||||
/// <summary>Gets the default OpenAI endpoint.</summary>
|
||||
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");
|
||||
|
||||
/// <summary>Gets a <see cref="ChatRole"/> for "developer".</summary>
|
||||
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");
|
||||
|
||||
/// <summary>Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.</summary>
|
||||
/// <param name="json">The input arguments to be parsed.</param>
|
||||
/// <param name="callId">The function call ID.</param>
|
||||
/// <param name="name">The function name.</param>
|
||||
/// <returns>A new instance of <see cref="FunctionCallContent"/> containing the parse result.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="callId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
internal static FunctionCallContent ParseCallContent(string json, string callId, string name) =>
|
||||
FunctionCallContent.CreateFromParsedArguments(json, callId, name,
|
||||
static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!);
|
||||
|
||||
/// <summary>Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.</summary>
|
||||
/// <param name="utf8json">The input arguments to be parsed.</param>
|
||||
/// <param name="callId">The function call ID.</param>
|
||||
/// <param name="name">The function name.</param>
|
||||
/// <returns>A new instance of <see cref="FunctionCallContent"/> containing the parse result.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="callId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) =>
|
||||
FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name,
|
||||
static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext.Default.IDictionaryStringObject)!);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JSON schema transformer cache conforming to OpenAI <b>strict</b> / structured output restrictions per
|
||||
/// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
|
||||
/// </summary>
|
||||
internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
|
||||
{
|
||||
DisallowAdditionalProperties = true,
|
||||
ConvertBooleanSchemas = true,
|
||||
MoveDefaultKeywordToDescription = true,
|
||||
RequireAllProperties = true,
|
||||
TransformSchemaNode = (ctx, node) =>
|
||||
{
|
||||
// Move content from common but unsupported properties to description. In particular, we focus on properties that
|
||||
// the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation.
|
||||
|
||||
if (node is JsonObject schemaObj)
|
||||
{
|
||||
StringBuilder? additionalDescription = null;
|
||||
|
||||
ReadOnlySpan<string> unsupportedProperties =
|
||||
[
|
||||
// Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties:
|
||||
"contentEncoding", "contentMediaType", "not",
|
||||
|
||||
// Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models:
|
||||
"minLength", "maxLength", "pattern", "format",
|
||||
"minimum", "maximum", "multipleOf",
|
||||
"patternProperties",
|
||||
"minItems", "maxItems",
|
||||
|
||||
// Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords
|
||||
// as being unsupported with Azure OpenAI:
|
||||
"unevaluatedProperties", "propertyNames", "minProperties", "maxProperties",
|
||||
"unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems",
|
||||
];
|
||||
|
||||
foreach (string propName in unsupportedProperties)
|
||||
{
|
||||
if (schemaObj[propName] is { } propNode)
|
||||
{
|
||||
_ = schemaObj.Remove(propName);
|
||||
AppendLine(ref additionalDescription, propName, propNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalDescription is not null)
|
||||
{
|
||||
schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ?
|
||||
$"{descriptionNode.GetValue<string>()}{Environment.NewLine}{additionalDescription}" :
|
||||
additionalDescription.ToString();
|
||||
}
|
||||
|
||||
return node;
|
||||
|
||||
static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode)
|
||||
{
|
||||
sb ??= new();
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
_ = sb.AppendLine();
|
||||
}
|
||||
|
||||
_ = sb.Append(propName).Append(": ").Append(propNode);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict.
|
||||
|
||||
/// <summary>Gets whether the properties specify that strict schema handling is desired.</summary>
|
||||
internal static bool? HasStrict(IReadOnlyDictionary<string, object?>? additionalProperties) =>
|
||||
additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true &&
|
||||
strictObj is bool strictValue ?
|
||||
strictValue : null;
|
||||
|
||||
/// <summary>Extracts from an <see cref="AIFunction"/> the parameters and strictness setting for use with OpenAI's APIs.</summary>
|
||||
internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict)
|
||||
{
|
||||
// Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting.
|
||||
JsonElement jsonSchema = strict is true ?
|
||||
StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
|
||||
aiFunction.JsonSchema;
|
||||
|
||||
// Roundtrip the schema through the ToolJson model type to remove extra properties
|
||||
// and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData.
|
||||
var tool = jsonSchema.Deserialize(OpenAIJsonContext.Default.ToolJson)!;
|
||||
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson));
|
||||
|
||||
return functionParameters;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporary extension methods to assist with creating proposed <see cref="IChatClient"/> changed instances
|
||||
/// </summary>
|
||||
public static class OpenAIAssistantsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of an <see cref="IChatClient"/> configured for the specified assistant.
|
||||
/// </summary>
|
||||
/// <param name="client">The <see cref="AssistantClient"/> instance used to initialize the chat client. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="assistantId">The unique identifier of the assistant. Cannot be <see langword="null"/> or whitespace.</param>
|
||||
/// <param name="defaultThreadId">The optional default thread identifier for the chat client. Can be <see langword="null"/>.</param>
|
||||
/// <returns>A new <see cref="IChatClient"/> instance configured with the specified assistant and optional default thread.</returns>
|
||||
public static IChatClient AsNewIChatClient(this AssistantClient client, string assistantId, string? defaultThreadId = null) =>
|
||||
new NewOpenAIAssistantsChatClient(Throw.IfNull(client), Throw.IfNullOrWhitespace(assistantId), defaultThreadId);
|
||||
}
|
||||
@@ -1,918 +0,0 @@
|
||||
#pragma warning disable IDE0073 // The file header does not match the required text
|
||||
#pragma warning disable CA1063 // Implement IDisposable Correctly
|
||||
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable S907 // "goto" statement should not be used
|
||||
#pragma warning disable S1067 // Expressions should not be too complex
|
||||
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
|
||||
#pragma warning disable S3604 // Member initializer values should not be redundant
|
||||
#pragma warning disable SA1202 // Elements should be ordered by access
|
||||
#pragma warning disable SA1204 // Static elements should appear before instance elements
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>Represents an <see cref="IChatClient"/> for an <see cref="OpenAIResponseClient"/>.</summary>
|
||||
internal sealed class NewOpenAIResponsesChatClient : IChatClient
|
||||
{
|
||||
/// <summary>Type info for serializing and deserializing arbitrary JSON objects.</summary>
|
||||
private static readonly JsonTypeInfo s_jsonTypeInfo = AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object));
|
||||
|
||||
/// <summary>Metadata about the client.</summary>
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
|
||||
/// <summary>The underlying <see cref="OpenAIResponseClient" />.</summary>
|
||||
private readonly OpenAIResponseClient _responseClient;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="NewOpenAIResponsesChatClient"/> class for the specified <see cref="OpenAIResponseClient"/>.</summary>
|
||||
/// <param name="responseClient">The underlying client.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
|
||||
public NewOpenAIResponsesChatClient(OpenAIResponseClient responseClient)
|
||||
{
|
||||
_ = Throw.IfNull(responseClient);
|
||||
|
||||
_responseClient = responseClient;
|
||||
|
||||
// https://github.com/openai/openai-dotnet/issues/215
|
||||
// The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages
|
||||
// implement the abstractions directly rather than providing adapters on top of the public APIs,
|
||||
// the package can provide such implementations separate from what's exposed in the public API.
|
||||
Uri providerUrl = typeof(OpenAIResponseClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?.GetValue(responseClient) as Uri ?? OpenAIClientExtensions3.DefaultOpenAIEndpoint;
|
||||
string? model = typeof(OpenAIResponseClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?.GetValue(responseClient) as string;
|
||||
|
||||
_metadata = new("openai", providerUrl, model);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
object? IChatClient.GetService(Type serviceType, object? serviceKey)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
return
|
||||
serviceKey is not null ? null :
|
||||
serviceType == typeof(ChatClientMetadata) ? _metadata :
|
||||
serviceType == typeof(OpenAIResponseClient) ? _responseClient :
|
||||
serviceType.IsInstanceOfType(this) ? this :
|
||||
null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
|
||||
// Convert the inputs into what OpenAIResponseClient expects.
|
||||
var openAIResponseItems = ToOpenAIResponseItems(messages, options);
|
||||
var openAIOptions = ToOpenAIResponseCreationOptions(options);
|
||||
|
||||
// Make the call to the OpenAIResponseClient.
|
||||
var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
// Convert the response to a ChatResponse.
|
||||
return FromOpenAIResponse(openAIResponse, openAIOptions);
|
||||
}
|
||||
|
||||
internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions)
|
||||
{
|
||||
// Convert and return the results.
|
||||
ChatResponse response = new()
|
||||
{
|
||||
ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id,
|
||||
CreatedAt = openAIResponse.CreatedAt,
|
||||
FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason),
|
||||
ModelId = openAIResponse.Model,
|
||||
RawRepresentation = openAIResponse,
|
||||
ResponseId = openAIResponse.Id,
|
||||
Usage = ToUsageDetails(openAIResponse),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(openAIResponse.EndUserId))
|
||||
{
|
||||
(response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId;
|
||||
}
|
||||
|
||||
if (openAIResponse.Error is not null)
|
||||
{
|
||||
(response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error;
|
||||
}
|
||||
|
||||
if (openAIResponse.OutputItems is not null)
|
||||
{
|
||||
response.Messages = [.. ToChatMessages(openAIResponse.OutputItems)];
|
||||
|
||||
if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error)
|
||||
{
|
||||
lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() });
|
||||
}
|
||||
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
message.CreatedAt ??= openAIResponse.CreatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
internal static IEnumerable<ChatMessage> ToChatMessages(IEnumerable<ResponseItem> items)
|
||||
{
|
||||
ChatMessage? message = null;
|
||||
|
||||
foreach (ResponseItem outputItem in items)
|
||||
{
|
||||
message ??= new(ChatRole.Assistant, (string?)null);
|
||||
|
||||
switch (outputItem)
|
||||
{
|
||||
case MessageResponseItem messageItem:
|
||||
if (message.MessageId is not null && message.MessageId != messageItem.Id)
|
||||
{
|
||||
yield return message;
|
||||
message = new ChatMessage();
|
||||
}
|
||||
|
||||
message.MessageId = messageItem.Id;
|
||||
message.RawRepresentation = messageItem;
|
||||
message.Role = ToChatRole(messageItem.Role);
|
||||
((List<AIContent>)message.Contents).AddRange(ToAIContents(messageItem.Content));
|
||||
break;
|
||||
|
||||
case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary:
|
||||
message.Contents.Add(new TextReasoningContent(summary) { RawRepresentation = outputItem });
|
||||
break;
|
||||
|
||||
case FunctionCallResponseItem functionCall:
|
||||
var fcc = OpenAIClientExtensions3.ParseCallContent(functionCall.FunctionArguments, functionCall.CallId, functionCall.FunctionName);
|
||||
fcc.RawRepresentation = outputItem;
|
||||
message.Contents.Add(fcc);
|
||||
break;
|
||||
|
||||
case FunctionCallOutputResponseItem functionCallOutputItem:
|
||||
message.Contents.Add(new FunctionResultContent(functionCallOutputItem.CallId, functionCallOutputItem.FunctionOutput) { RawRepresentation = functionCallOutputItem });
|
||||
break;
|
||||
|
||||
default:
|
||||
message.Contents.Add(new() { RawRepresentation = outputItem });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (message is not null)
|
||||
{
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
|
||||
var openAIResponseItems = ToOpenAIResponseItems(messages, options);
|
||||
var openAIOptions = ToOpenAIResponseCreationOptions(options);
|
||||
|
||||
var streamingUpdates = _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken);
|
||||
|
||||
return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken);
|
||||
}
|
||||
|
||||
internal static async IAsyncEnumerable<ChatResponseUpdate> FromOpenAIStreamingResponseUpdatesAsync(
|
||||
IAsyncEnumerable<StreamingResponseUpdate> streamingResponseUpdates, ResponseCreationOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
DateTimeOffset? createdAt = null;
|
||||
string? responseId = null;
|
||||
string? conversationId = null;
|
||||
string? modelId = null;
|
||||
string? lastMessageId = null;
|
||||
ChatRole? lastRole = null;
|
||||
Dictionary<int, MessageResponseItem> outputIndexToMessages = [];
|
||||
Dictionary<int, FunctionCallInfo>? functionCallInfos = null;
|
||||
|
||||
await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Create an update populated with the current state of the response.
|
||||
ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
|
||||
new(lastRole, content is not null ? [content] : null)
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
CreatedAt = createdAt,
|
||||
MessageId = lastMessageId,
|
||||
ModelId = modelId,
|
||||
RawRepresentation = streamingUpdate,
|
||||
ResponseId = responseId,
|
||||
};
|
||||
|
||||
switch (streamingUpdate)
|
||||
{
|
||||
case StreamingResponseCreatedUpdate createdUpdate:
|
||||
createdAt = createdUpdate.Response.CreatedAt;
|
||||
responseId = createdUpdate.Response.Id;
|
||||
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
|
||||
modelId = createdUpdate.Response.Model;
|
||||
goto default;
|
||||
|
||||
case StreamingResponseCompletedUpdate completedUpdate:
|
||||
{
|
||||
var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null);
|
||||
update.FinishReason =
|
||||
ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ??
|
||||
(functionCallInfos is not null ? ChatFinishReason.ToolCalls :
|
||||
ChatFinishReason.Stop);
|
||||
yield return update;
|
||||
break;
|
||||
}
|
||||
|
||||
case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate:
|
||||
switch (outputItemAddedUpdate.Item)
|
||||
{
|
||||
case MessageResponseItem mri:
|
||||
outputIndexToMessages[outputItemAddedUpdate.OutputIndex] = mri;
|
||||
break;
|
||||
|
||||
case FunctionCallResponseItem fcri:
|
||||
(functionCallInfos ??= [])[outputItemAddedUpdate.OutputIndex] = new(fcri);
|
||||
break;
|
||||
}
|
||||
|
||||
goto default;
|
||||
|
||||
case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate:
|
||||
_ = outputIndexToMessages.Remove(outputItemDoneUpdate.OutputIndex);
|
||||
|
||||
if (outputItemDoneUpdate.Item is MessageResponseItem item &&
|
||||
item.Content is { Count: > 0 } content &&
|
||||
content.Any(c => c.OutputTextAnnotations is { Count: > 0 }))
|
||||
{
|
||||
AIContent annotatedContent = new();
|
||||
foreach (var c in content)
|
||||
{
|
||||
PopulateAnnotations(c, annotatedContent);
|
||||
}
|
||||
|
||||
yield return CreateUpdate(annotatedContent);
|
||||
break;
|
||||
}
|
||||
|
||||
goto default;
|
||||
|
||||
case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate:
|
||||
{
|
||||
_ = outputIndexToMessages.TryGetValue(outputTextDeltaUpdate.OutputIndex, out MessageResponseItem? messageItem);
|
||||
lastMessageId = messageItem?.Id;
|
||||
lastRole = ToChatRole(messageItem?.Role);
|
||||
|
||||
yield return CreateUpdate(new TextContent(outputTextDeltaUpdate.Delta));
|
||||
break;
|
||||
}
|
||||
|
||||
case StreamingResponseFunctionCallArgumentsDeltaUpdate functionCallArgumentsDeltaUpdate:
|
||||
{
|
||||
if (functionCallInfos?.TryGetValue(functionCallArgumentsDeltaUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true)
|
||||
{
|
||||
_ = (callInfo.Arguments ??= new()).Append(functionCallArgumentsDeltaUpdate.Delta);
|
||||
}
|
||||
|
||||
goto default;
|
||||
}
|
||||
|
||||
case StreamingResponseFunctionCallArgumentsDoneUpdate functionCallOutputDoneUpdate:
|
||||
{
|
||||
if (functionCallInfos?.TryGetValue(functionCallOutputDoneUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true)
|
||||
{
|
||||
_ = functionCallInfos.Remove(functionCallOutputDoneUpdate.OutputIndex);
|
||||
|
||||
var fcc = OpenAIClientExtensions3.ParseCallContent(
|
||||
callInfo.Arguments?.ToString() ?? string.Empty,
|
||||
callInfo.ResponseItem.CallId,
|
||||
callInfo.ResponseItem.FunctionName);
|
||||
|
||||
lastMessageId = callInfo.ResponseItem.Id;
|
||||
lastRole = ChatRole.Assistant;
|
||||
|
||||
yield return CreateUpdate(fcc);
|
||||
break;
|
||||
}
|
||||
|
||||
goto default;
|
||||
}
|
||||
|
||||
case StreamingResponseErrorUpdate errorUpdate:
|
||||
yield return CreateUpdate(new ErrorContent(errorUpdate.Message)
|
||||
{
|
||||
ErrorCode = errorUpdate.Code,
|
||||
Details = errorUpdate.Param,
|
||||
});
|
||||
break;
|
||||
|
||||
case StreamingResponseRefusalDoneUpdate refusalDone:
|
||||
yield return CreateUpdate(new ErrorContent(refusalDone.Refusal)
|
||||
{
|
||||
ErrorCode = nameof(ResponseContentPart.Refusal),
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
{
|
||||
// Capture streaming thinking contents
|
||||
if (streamingUpdate.GetType().Name == "InternalResponseReasoningSummaryTextDeltaEvent")
|
||||
{
|
||||
var updateJson = JsonSerializer.Deserialize<JsonElement>(
|
||||
JsonSerializer.Serialize(streamingUpdate, s_jsonTypeInfo),
|
||||
OpenAIJsonContext2.Default.JsonElement);
|
||||
|
||||
if (updateJson.TryGetProperty("delta", out var deltaProperty))
|
||||
{
|
||||
yield return CreateUpdate(new TextReasoningContent(deltaProperty.GetString()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
yield return CreateUpdate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Nothing to dispose. Implementation required for the IChatClient interface.
|
||||
}
|
||||
|
||||
internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null)
|
||||
{
|
||||
bool? strict =
|
||||
OpenAIClientExtensions3.HasStrict(aiFunction.AdditionalProperties) ??
|
||||
OpenAIClientExtensions3.HasStrict(options?.AdditionalProperties);
|
||||
|
||||
return ResponseTool.CreateFunctionTool(
|
||||
aiFunction.Name,
|
||||
aiFunction.Description,
|
||||
OpenAIClientExtensions3.ToOpenAIFunctionParameters(aiFunction, strict),
|
||||
strict ?? false);
|
||||
}
|
||||
|
||||
/// <summary>Creates a <see cref="ChatRole"/> from a <see cref="OpenAI.Responses.MessageRole"/>.</summary>
|
||||
private static ChatRole ToChatRole(OpenAI.Responses.MessageRole? role) =>
|
||||
role switch
|
||||
{
|
||||
OpenAI.Responses.MessageRole.System => ChatRole.System,
|
||||
OpenAI.Responses.MessageRole.Developer => OpenAIClientExtensions3.ChatRoleDeveloper,
|
||||
OpenAI.Responses.MessageRole.User => ChatRole.User,
|
||||
_ => ChatRole.Assistant,
|
||||
};
|
||||
|
||||
/// <summary>Creates a <see cref="ChatFinishReason"/> from a <see cref="ResponseIncompleteStatusReason"/>.</summary>
|
||||
private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) =>
|
||||
statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter :
|
||||
statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length :
|
||||
null;
|
||||
|
||||
/// <summary>Converts a <see cref="ChatOptions"/> to a <see cref="ResponseCreationOptions"/>.</summary>
|
||||
private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return new ResponseCreationOptions();
|
||||
}
|
||||
|
||||
if (options.RawRepresentationFactory?.Invoke(this) is not ResponseCreationOptions result)
|
||||
{
|
||||
result = new ResponseCreationOptions();
|
||||
}
|
||||
|
||||
// Handle strongly-typed properties.
|
||||
result.MaxOutputTokenCount ??= options.MaxOutputTokens;
|
||||
result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls;
|
||||
result.PreviousResponseId ??= options.ConversationId;
|
||||
result.Temperature ??= options.Temperature;
|
||||
result.TopP ??= options.TopP;
|
||||
|
||||
if (options.Instructions is { } instructions)
|
||||
{
|
||||
result.Instructions = string.IsNullOrEmpty(result.Instructions) ?
|
||||
instructions :
|
||||
$"{result.Instructions}{Environment.NewLine}{instructions}";
|
||||
}
|
||||
|
||||
// Populate tools if there are any.
|
||||
if (options.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
foreach (AITool tool in tools)
|
||||
{
|
||||
switch (tool)
|
||||
{
|
||||
case AIFunction aiFunction:
|
||||
result.Tools.Add(ToResponseTool(aiFunction, options));
|
||||
break;
|
||||
|
||||
case HostedWebSearchTool webSearchTool:
|
||||
WebSearchUserLocation? location = null;
|
||||
if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchUserLocation), out object? objLocation))
|
||||
{
|
||||
location = objLocation as WebSearchUserLocation;
|
||||
}
|
||||
|
||||
WebSearchContextSize? size = null;
|
||||
if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchContextSize), out object? objSize) &&
|
||||
objSize is WebSearchContextSize)
|
||||
{
|
||||
size = (WebSearchContextSize)objSize;
|
||||
}
|
||||
|
||||
result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size));
|
||||
break;
|
||||
|
||||
case HostedFileSearchTool fileSearchTool:
|
||||
result.Tools.Add(ResponseTool.CreateFileSearchTool(
|
||||
fileSearchTool.Inputs?.OfType<HostedVectorStoreContent>().Select(c => c.VectorStoreId) ?? [],
|
||||
fileSearchTool.MaximumResultCount));
|
||||
break;
|
||||
|
||||
case HostedCodeInterpreterTool codeTool:
|
||||
string json;
|
||||
if (codeTool.Inputs is { Count: > 0 } inputs)
|
||||
{
|
||||
string jsonArray = JsonSerializer.Serialize(
|
||||
inputs.OfType<HostedFileContent>().Select(c => c.FileId),
|
||||
OpenAIJsonContext2.Default.IEnumerableString);
|
||||
json = $$"""{"type":"code_interpreter","container":{"type":"auto",files:{{jsonArray}}} }""";
|
||||
}
|
||||
else
|
||||
{
|
||||
json = """{"type":"code_interpreter","container":"auto"}""";
|
||||
}
|
||||
|
||||
result.Tools.Add(ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(json)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ToolChoice is null && result.Tools.Count > 0)
|
||||
{
|
||||
switch (options.ToolMode)
|
||||
{
|
||||
case NoneChatToolMode:
|
||||
result.ToolChoice = ResponseToolChoice.CreateNoneChoice();
|
||||
break;
|
||||
|
||||
case AutoChatToolMode:
|
||||
case null:
|
||||
result.ToolChoice = ResponseToolChoice.CreateAutoChoice();
|
||||
break;
|
||||
|
||||
case RequiredChatToolMode required:
|
||||
result.ToolChoice = required.RequiredFunctionName is not null ?
|
||||
ResponseToolChoice.CreateFunctionChoice(required.RequiredFunctionName) :
|
||||
ResponseToolChoice.CreateRequiredChoice();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.TextOptions is null)
|
||||
{
|
||||
if (options.ResponseFormat is ChatResponseFormatText)
|
||||
{
|
||||
result.TextOptions = new()
|
||||
{
|
||||
TextFormat = ResponseTextFormat.CreateTextFormat()
|
||||
};
|
||||
}
|
||||
else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat)
|
||||
{
|
||||
result.TextOptions = new()
|
||||
{
|
||||
TextFormat = OpenAIClientExtensions3.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
|
||||
ResponseTextFormat.CreateJsonSchemaFormat(
|
||||
jsonFormat.SchemaName ?? "json_schema",
|
||||
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext2.Default.JsonElement)),
|
||||
jsonFormat.SchemaDescription,
|
||||
OpenAIClientExtensions3.HasStrict(options.AdditionalProperties)) :
|
||||
ResponseTextFormat.CreateJsonObjectFormat(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Convert a sequence of <see cref="ChatMessage"/>s to <see cref="ResponseItem"/>s.</summary>
|
||||
internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<ChatMessage> inputs, ChatOptions? options)
|
||||
{
|
||||
_ = options; // currently unused
|
||||
|
||||
foreach (ChatMessage input in inputs)
|
||||
{
|
||||
if (input.Role == ChatRole.System ||
|
||||
input.Role == OpenAIClientExtensions3.ChatRoleDeveloper)
|
||||
{
|
||||
string text = input.Text;
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
yield return input.Role == ChatRole.System ?
|
||||
ResponseItem.CreateSystemMessageItem(text) :
|
||||
ResponseItem.CreateDeveloperMessageItem(text);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.Role == ChatRole.User)
|
||||
{
|
||||
yield return ResponseItem.CreateUserMessageItem(ToResponseContentParts(input.Contents));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.Role == ChatRole.Tool)
|
||||
{
|
||||
foreach (AIContent item in input.Contents)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case AIContent when item.RawRepresentation is ResponseItem rawRep:
|
||||
yield return rawRep;
|
||||
break;
|
||||
|
||||
case FunctionResultContent resultContent:
|
||||
string? result = resultContent.Result as string;
|
||||
if (result is null && resultContent.Result is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// If the type can't be serialized, skip it.
|
||||
}
|
||||
}
|
||||
|
||||
yield return ResponseItem.CreateFunctionCallOutputItem(resultContent.CallId, result ?? string.Empty);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.Role == ChatRole.Assistant)
|
||||
{
|
||||
foreach (AIContent item in input.Contents)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case AIContent when item.RawRepresentation is ResponseItem rawRep:
|
||||
yield return rawRep;
|
||||
break;
|
||||
|
||||
case TextContent textContent:
|
||||
yield return ResponseItem.CreateAssistantMessageItem(textContent.Text);
|
||||
break;
|
||||
|
||||
case TextReasoningContent reasoningContent:
|
||||
yield return ResponseItem.CreateReasoningItem(reasoningContent.Text);
|
||||
break;
|
||||
|
||||
case FunctionCallContent callContent:
|
||||
yield return ResponseItem.CreateFunctionCallItem(
|
||||
callContent.CallId,
|
||||
callContent.Name,
|
||||
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(
|
||||
callContent.Arguments,
|
||||
AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary<string, object?>)))));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extract usage details from an <see cref="OpenAIResponse"/>.</summary>
|
||||
private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse)
|
||||
{
|
||||
UsageDetails? ud = null;
|
||||
if (openAIResponse?.Usage is { } usage)
|
||||
{
|
||||
ud = new()
|
||||
{
|
||||
InputTokenCount = usage.InputTokenCount,
|
||||
OutputTokenCount = usage.OutputTokenCount,
|
||||
TotalTokenCount = usage.TotalTokenCount,
|
||||
};
|
||||
|
||||
if (usage.InputTokenDetails is { } inputDetails)
|
||||
{
|
||||
ud.AdditionalCounts ??= [];
|
||||
ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount);
|
||||
}
|
||||
|
||||
if (usage.OutputTokenDetails is { } outputDetails)
|
||||
{
|
||||
ud.AdditionalCounts ??= [];
|
||||
ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount);
|
||||
}
|
||||
}
|
||||
|
||||
return ud;
|
||||
}
|
||||
|
||||
/// <summary>Convert a sequence of <see cref="ResponseContentPart"/>s to a list of <see cref="AIContent"/>.</summary>
|
||||
private static List<AIContent> ToAIContents(IEnumerable<ResponseContentPart> contents)
|
||||
{
|
||||
List<AIContent> results = [];
|
||||
|
||||
foreach (ResponseContentPart part in contents)
|
||||
{
|
||||
switch (part.Kind)
|
||||
{
|
||||
case ResponseContentPartKind.InputText or ResponseContentPartKind.OutputText:
|
||||
TextContent text = new(part.Text) { RawRepresentation = part };
|
||||
PopulateAnnotations(part, text);
|
||||
results.Add(text);
|
||||
break;
|
||||
|
||||
case ResponseContentPartKind.InputFile:
|
||||
if (!string.IsNullOrWhiteSpace(part.InputImageFileId))
|
||||
{
|
||||
results.Add(new HostedFileContent(part.InputImageFileId) { RawRepresentation = part });
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(part.InputFileId))
|
||||
{
|
||||
results.Add(new HostedFileContent(part.InputFileId) { RawRepresentation = part });
|
||||
}
|
||||
else if (part.InputFileBytes is not null)
|
||||
{
|
||||
results.Add(new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream")
|
||||
{
|
||||
Name = part.InputFilename,
|
||||
RawRepresentation = part,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ResponseContentPartKind.Refusal:
|
||||
results.Add(new ErrorContent(part.Refusal)
|
||||
{
|
||||
ErrorCode = nameof(ResponseContentPartKind.Refusal),
|
||||
RawRepresentation = part,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
results.Add(new() { RawRepresentation = part });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Converts any annotations from <paramref name="source"/> and stores them in <paramref name="destination"/>.</summary>
|
||||
private static void PopulateAnnotations(ResponseContentPart source, AIContent destination)
|
||||
{
|
||||
if (source.OutputTextAnnotations is { Count: > 0 })
|
||||
{
|
||||
foreach (var ota in source.OutputTextAnnotations)
|
||||
{
|
||||
(destination.Annotations ??= []).Add(new CitationAnnotation
|
||||
{
|
||||
RawRepresentation = ota,
|
||||
AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ota.UriCitationStartIndex, EndIndex = ota.UriCitationEndIndex }],
|
||||
Title = ota.UriCitationTitle,
|
||||
Url = ota.UriCitationUri,
|
||||
FileId = ota.FileCitationFileId ?? ota.FilePathFileId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convert a list of <see cref="AIContent"/>s to a list of <see cref="ResponseContentPart"/>.</summary>
|
||||
private static List<ResponseContentPart> ToResponseContentParts(IList<AIContent> contents)
|
||||
{
|
||||
List<ResponseContentPart> parts = [];
|
||||
foreach (var content in contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case AIContent when content.RawRepresentation is ResponseContentPart rawRep:
|
||||
parts.Add(rawRep);
|
||||
break;
|
||||
|
||||
case TextContent textContent:
|
||||
parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text));
|
||||
break;
|
||||
|
||||
case UriContent uriContent when uriContent.HasTopLevelMediaType("image"):
|
||||
parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri));
|
||||
break;
|
||||
|
||||
case DataContent dataContent when dataContent.HasTopLevelMediaType("image"):
|
||||
parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType));
|
||||
break;
|
||||
|
||||
case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase):
|
||||
parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"));
|
||||
break;
|
||||
|
||||
case HostedFileContent fileContent:
|
||||
parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId));
|
||||
break;
|
||||
|
||||
case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal):
|
||||
parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// <summary>POCO representing function calling info.</summary>
|
||||
/// <remarks>Used to concatenation information for a single function call from across multiple streaming updates.</remarks>
|
||||
private sealed class FunctionCallInfo(FunctionCallResponseItem item)
|
||||
{
|
||||
public readonly FunctionCallResponseItem ResponseItem = item;
|
||||
public StringBuilder? Arguments;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class OpenAIClientExtensions3
|
||||
{
|
||||
/// <summary>Key into AdditionalProperties used to store a strict option.</summary>
|
||||
private const string StrictKey = "strictJsonSchema";
|
||||
|
||||
/// <summary>Gets the default OpenAI endpoint.</summary>
|
||||
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");
|
||||
|
||||
/// <summary>Gets a <see cref="ChatRole"/> for "developer".</summary>
|
||||
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JSON schema transformer cache conforming to OpenAI <b>strict</b> / structured output restrictions per
|
||||
/// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
|
||||
/// </summary>
|
||||
internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
|
||||
{
|
||||
DisallowAdditionalProperties = true,
|
||||
ConvertBooleanSchemas = true,
|
||||
MoveDefaultKeywordToDescription = true,
|
||||
RequireAllProperties = true,
|
||||
TransformSchemaNode = (ctx, node) =>
|
||||
{
|
||||
// Move content from common but unsupported properties to description. In particular, we focus on properties that
|
||||
// the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation.
|
||||
|
||||
if (node is JsonObject schemaObj)
|
||||
{
|
||||
StringBuilder? additionalDescription = null;
|
||||
|
||||
ReadOnlySpan<string> unsupportedProperties =
|
||||
[
|
||||
// Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties:
|
||||
"contentEncoding", "contentMediaType", "not",
|
||||
|
||||
// Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models:
|
||||
"minLength", "maxLength", "pattern", "format",
|
||||
"minimum", "maximum", "multipleOf",
|
||||
"patternProperties",
|
||||
"minItems", "maxItems",
|
||||
|
||||
// Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords
|
||||
// as being unsupported with Azure OpenAI:
|
||||
"unevaluatedProperties", "propertyNames", "minProperties", "maxProperties",
|
||||
"unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems",
|
||||
];
|
||||
|
||||
foreach (string propName in unsupportedProperties)
|
||||
{
|
||||
if (schemaObj[propName] is { } propNode)
|
||||
{
|
||||
_ = schemaObj.Remove(propName);
|
||||
AppendLine(ref additionalDescription, propName, propNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalDescription is not null)
|
||||
{
|
||||
schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ?
|
||||
$"{descriptionNode.GetValue<string>()}{Environment.NewLine}{additionalDescription}" :
|
||||
additionalDescription.ToString();
|
||||
}
|
||||
|
||||
return node;
|
||||
|
||||
static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode)
|
||||
{
|
||||
sb ??= new();
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
_ = sb.AppendLine();
|
||||
}
|
||||
|
||||
_ = sb.Append(propName).Append(": ").Append(propNode);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
});
|
||||
|
||||
/// <summary>Gets whether the properties specify that strict schema handling is desired.</summary>
|
||||
internal static bool? HasStrict(IReadOnlyDictionary<string, object?>? additionalProperties) =>
|
||||
additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true &&
|
||||
strictObj is bool strictValue ?
|
||||
strictValue : null;
|
||||
|
||||
/// <summary>Extracts from an <see cref="AIFunction"/> the parameters and strictness setting for use with OpenAI's APIs.</summary>
|
||||
internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict)
|
||||
{
|
||||
// Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting.
|
||||
JsonElement jsonSchema = strict is true ?
|
||||
StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
|
||||
aiFunction.JsonSchema;
|
||||
|
||||
// Roundtrip the schema through the ToolJson model type to remove extra properties
|
||||
// and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData.
|
||||
var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext2.Default.ToolJson)!;
|
||||
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext2.Default.ToolJson));
|
||||
|
||||
return functionParameters;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.</summary>
|
||||
/// <param name="json">The input arguments to be parsed.</param>
|
||||
/// <param name="callId">The function call ID.</param>
|
||||
/// <param name="name">The function name.</param>
|
||||
/// <returns>A new instance of <see cref="FunctionCallContent"/> containing the parse result.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="callId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
internal static FunctionCallContent ParseCallContent(string json, string callId, string name) =>
|
||||
FunctionCallContent.CreateFromParsedArguments(json, callId, name,
|
||||
static json => JsonSerializer.Deserialize(json, OpenAIJsonContext2.Default.IDictionaryStringObject)!);
|
||||
|
||||
/// <summary>Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.</summary>
|
||||
/// <param name="utf8json">The input arguments to be parsed.</param>
|
||||
/// <param name="callId">The function call ID.</param>
|
||||
/// <param name="name">The function name.</param>
|
||||
/// <returns>A new instance of <see cref="FunctionCallContent"/> containing the parse result.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="callId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) =>
|
||||
FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name,
|
||||
static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext2.Default.IDictionaryStringObject)!);
|
||||
|
||||
/// <summary>Used to create the JSON payload for an OpenAI tool description.</summary>
|
||||
internal sealed class ToolJson
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "object";
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public HashSet<string> Required { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public Dictionary<string, JsonElement> Properties { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("additionalProperties")]
|
||||
public bool AdditionalProperties { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Source-generated JSON type information for use by all OpenAI implementations.</summary>
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true)]
|
||||
[JsonSerializable(typeof(OpenAIClientExtensions3.ToolJson))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(IEnumerable<string>))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
internal sealed partial class OpenAIJsonContext2 : JsonSerializerContext;
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -18,13 +19,13 @@ internal static class ChatClientExtensions
|
||||
chatBuilder.UseAgentInvocation();
|
||||
}
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null && chatClient.GetService<NewFunctionInvokingChatClient>() is null)
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
_ = chatBuilder.Use((IChatClient innerClient, IServiceProvider services) =>
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
|
||||
return new NewFunctionInvokingChatClient(innerClient, loggerFactory, services);
|
||||
return new FunctionInvokingChatClient(innerClient, loggerFactory, services);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,17 +34,9 @@ internal static class ChatClientExtensions
|
||||
if (options?.ChatOptions?.Tools is { Count: > 0 })
|
||||
{
|
||||
// When tools are provided in the constructor, set the tools for the whole lifecycle of the chat client
|
||||
var newFunctionService = agentChatClient.GetService<NewFunctionInvokingChatClient>();
|
||||
var oldFunctionService = agentChatClient.GetService<FunctionInvokingChatClient>();
|
||||
|
||||
if (newFunctionService is not null)
|
||||
{
|
||||
newFunctionService.AdditionalTools = options.ChatOptions.Tools;
|
||||
}
|
||||
else
|
||||
{
|
||||
oldFunctionService!.AdditionalTools = options.ChatOptions.Tools;
|
||||
}
|
||||
var functionService = agentChatClient.GetService<FunctionInvokingChatClient>();
|
||||
Debug.Assert(functionService is not null, "FunctionInvokingChatClient should be registered in the chat client.");
|
||||
functionService!.AdditionalTools = options.ChatOptions.Tools;
|
||||
}
|
||||
|
||||
return agentChatClient;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an existing <see cref="AIFunction"/> with additional metadata to indicate that it requires approval.
|
||||
/// </summary>
|
||||
/// <param name="function">The <see cref="AIFunction"/> that requires approval.</param>
|
||||
public sealed class ApprovalRequiredAIFunction(AIFunction function) : DelegatingAIFunction(function)
|
||||
{
|
||||
/// <summary>
|
||||
/// An optional callback that can be used to determine if the function call requires approval, instead of the default behavior, which is to always require approval.
|
||||
/// </summary>
|
||||
public Func<ApprovalContext, ValueTask<bool>> RequiresApprovalCallback { get; set; } = _ => new(true);
|
||||
|
||||
/// <summary>
|
||||
/// Context object that provides information about the function call that requires approval.
|
||||
/// </summary>
|
||||
public sealed class ApprovalContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ApprovalContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="functionCall">The <see cref="FunctionCallContent"/> containing the details of the invocation.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="functionCall"/> is null.</exception>
|
||||
public ApprovalContext(FunctionCallContent functionCall)
|
||||
{
|
||||
this.FunctionCall = Throw.IfNull(functionCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="FunctionCallContent"/> containing the details of the invocation that will be made if approval is granted.
|
||||
/// </summary>
|
||||
public FunctionCallContent FunctionCall { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// WARNING:
|
||||
// This class has been temporarily copied here from MEAI, to allow prototyping
|
||||
// functionality that will be moved to MEAI in the future.
|
||||
// This file is not intended to be modified.
|
||||
|
||||
#pragma warning disable CA1031 // Do not catch general exception types
|
||||
#pragma warning disable S108 // Nested blocks of code should not be left empty
|
||||
#pragma warning disable S2486 // Generic exceptions should not be ignored
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>Provides internal helpers for implementing logging.</summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static class LoggingHelpers
|
||||
{
|
||||
/// <summary>Serializes <paramref name="value"/> as JSON for logging purposes.</summary>
|
||||
public static string AsJson<T>(T value, JsonSerializerOptions? options)
|
||||
{
|
||||
if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true ||
|
||||
AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(value, typeInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// If we're unable to get a type info for the value, or if we fail to serialize,
|
||||
// return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions.
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// WARNING:
|
||||
// This class has been temporarily copied here from MEAI, to allow prototyping
|
||||
// functionality that will be moved to MEAI in the future.
|
||||
// This file is not intended to be modified.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable CA1716 // Identifiers should not match keywords
|
||||
#pragma warning disable S4041 // Type names should not match namespaces
|
||||
|
||||
/// <summary>Provides constants used by various telemetry services.</summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static class OpenTelemetryConsts
|
||||
{
|
||||
public const string DefaultSourceName = "Experimental.Microsoft.Extensions.AI";
|
||||
|
||||
public const string SecondsUnit = "s";
|
||||
public const string TokensUnit = "token";
|
||||
|
||||
public static class Event
|
||||
{
|
||||
public const string Name = "event.name";
|
||||
}
|
||||
|
||||
public static class Error
|
||||
{
|
||||
public const string Type = "error.type";
|
||||
}
|
||||
|
||||
public static class GenAI
|
||||
{
|
||||
public const string Choice = "gen_ai.choice";
|
||||
public const string SystemName = "gen_ai.system";
|
||||
|
||||
public const string Chat = "chat";
|
||||
public const string Embeddings = "embeddings";
|
||||
public const string ExecuteTool = "execute_tool";
|
||||
|
||||
public static class Assistant
|
||||
{
|
||||
public const string Message = "gen_ai.assistant.message";
|
||||
}
|
||||
|
||||
public static class Client
|
||||
{
|
||||
public static class OperationDuration
|
||||
{
|
||||
public const string Description = "Measures the duration of a GenAI operation";
|
||||
public const string Name = "gen_ai.client.operation.duration";
|
||||
public static readonly double[] ExplicitBucketBoundaries = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92];
|
||||
}
|
||||
|
||||
public static class TokenUsage
|
||||
{
|
||||
public const string Description = "Measures number of input and output tokens used";
|
||||
public const string Name = "gen_ai.client.token.usage";
|
||||
public static readonly int[] ExplicitBucketBoundaries = [1, 4, 16, 64, 256, 1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216, 67_108_864];
|
||||
}
|
||||
}
|
||||
|
||||
public static class Conversation
|
||||
{
|
||||
public const string Id = "gen_ai.conversation.id";
|
||||
}
|
||||
|
||||
public static class Operation
|
||||
{
|
||||
public const string Name = "gen_ai.operation.name";
|
||||
}
|
||||
|
||||
public static class Output
|
||||
{
|
||||
public const string Type = "gen_ai.output.type";
|
||||
}
|
||||
|
||||
public static class Request
|
||||
{
|
||||
public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions";
|
||||
public const string FrequencyPenalty = "gen_ai.request.frequency_penalty";
|
||||
public const string Model = "gen_ai.request.model";
|
||||
public const string MaxTokens = "gen_ai.request.max_tokens";
|
||||
public const string PresencePenalty = "gen_ai.request.presence_penalty";
|
||||
public const string Seed = "gen_ai.request.seed";
|
||||
public const string StopSequences = "gen_ai.request.stop_sequences";
|
||||
public const string Temperature = "gen_ai.request.temperature";
|
||||
public const string TopK = "gen_ai.request.top_k";
|
||||
public const string TopP = "gen_ai.request.top_p";
|
||||
|
||||
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}";
|
||||
}
|
||||
|
||||
public static class Response
|
||||
{
|
||||
public const string FinishReasons = "gen_ai.response.finish_reasons";
|
||||
public const string Id = "gen_ai.response.id";
|
||||
public const string Model = "gen_ai.response.model";
|
||||
|
||||
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}";
|
||||
}
|
||||
|
||||
public static class System
|
||||
{
|
||||
public const string Message = "gen_ai.system.message";
|
||||
}
|
||||
|
||||
public static class Token
|
||||
{
|
||||
public const string Type = "gen_ai.token.type";
|
||||
}
|
||||
|
||||
public static class Tool
|
||||
{
|
||||
public const string Name = "gen_ai.tool.name";
|
||||
public const string Description = "gen_ai.tool.description";
|
||||
public const string Message = "gen_ai.tool.message";
|
||||
|
||||
public static class Call
|
||||
{
|
||||
public const string Id = "gen_ai.tool.call.id";
|
||||
}
|
||||
}
|
||||
|
||||
public static class Usage
|
||||
{
|
||||
public const string InputTokens = "gen_ai.usage.input_tokens";
|
||||
public const string OutputTokens = "gen_ai.usage.output_tokens";
|
||||
}
|
||||
|
||||
public static class User
|
||||
{
|
||||
public const string Message = "gen_ai.user.message";
|
||||
}
|
||||
}
|
||||
|
||||
public static class Server
|
||||
{
|
||||
public const string Address = "server.address";
|
||||
public const string Port = "server.port";
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// WARNING:
|
||||
// This class has been temporarily copied here from MEAI, to allow prototyping
|
||||
// functionality that will be moved to MEAI in the future.
|
||||
// This file is not intended to be modified.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
internal static class AssertExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Asserts that the two function call parameters are equal, up to JSON equivalence.
|
||||
/// </summary>
|
||||
public static void EqualFunctionCallParameters(
|
||||
IDictionary<string, object?>? expected,
|
||||
IDictionary<string, object?>? actual,
|
||||
JsonSerializerOptions? options = null)
|
||||
{
|
||||
if (expected is null || actual is null)
|
||||
{
|
||||
Assert.Equal(expected, actual);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var expectedEntry in expected)
|
||||
{
|
||||
if (!actual.TryGetValue(expectedEntry.Key, out object? actualValue))
|
||||
{
|
||||
throw new XunitException($"Expected parameter '{expectedEntry.Key}' not found in actual value.");
|
||||
}
|
||||
|
||||
AreJsonEquivalentValues(expectedEntry.Value, actualValue, options, propertyName: expectedEntry.Key);
|
||||
}
|
||||
|
||||
if (expected.Count != actual.Count)
|
||||
{
|
||||
var extraParameters = actual
|
||||
.Where(e => !expected.ContainsKey(e.Key))
|
||||
.Select(e => $"'{e.Key}'")
|
||||
.First();
|
||||
|
||||
throw new XunitException($"Actual value contains additional parameters {string.Join(", ", extraParameters)} not found in expected value.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the two function call results are equal, up to JSON equivalence.
|
||||
/// </summary>
|
||||
public static void EqualFunctionCallResults(object? expected, object? actual, JsonSerializerOptions? options = null)
|
||||
=> AreJsonEquivalentValues(expected, actual, options);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the two JSON values are equal.
|
||||
/// </summary>
|
||||
public static void EqualJsonValues(JsonElement expectedJson, JsonElement actualJson, string? propertyName = null)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(
|
||||
JsonSerializer.SerializeToNode(expectedJson, AIJsonUtilities.DefaultOptions),
|
||||
JsonSerializer.SerializeToNode(actualJson, AIJsonUtilities.DefaultOptions)))
|
||||
{
|
||||
string message = propertyName is null
|
||||
? $"JSON result does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}"
|
||||
: $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}";
|
||||
|
||||
throw new XunitException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null)
|
||||
{
|
||||
options ??= AIJsonUtilities.DefaultOptions;
|
||||
JsonElement expectedElement = NormalizeToElement(expected, options);
|
||||
JsonElement actualElement = NormalizeToElement(actual, options);
|
||||
EqualJsonValues(expectedElement, actualElement, propertyName);
|
||||
|
||||
static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options)
|
||||
=> value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options);
|
||||
}
|
||||
}
|
||||
-931
@@ -1,931 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// AF repo suppressions for code copied from MEAI.
|
||||
#pragma warning disable CA5394 // Do not use insecure randomness
|
||||
#pragma warning disable IDE0009 // Member access should be qualified.
|
||||
#pragma warning disable IDE0039 // Use local function
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
public class NewFunctionInvokingChatClientTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task AllFunctionCallsReplacedWithApprovalsWhenAllRequireApprovalAsync(bool useAdditionalTools)
|
||||
{
|
||||
AITool[] tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")) { RequiresApprovalCallback = async (context) => context.FunctionCall.Name == "Func2" },
|
||||
];
|
||||
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools = useAdditionalTools ? null : tools
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
])
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
])
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditionalRequireApprovalAsync(bool additionalToolsRequireApproval)
|
||||
{
|
||||
AIFunction func1 = AIFunctionFactory.Create(() => "Result 1", "Func1");
|
||||
AIFunction func2 = AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2");
|
||||
AITool[] additionalTools =
|
||||
[
|
||||
additionalToolsRequireApproval ? new ApprovalRequiredAIFunction(func1) : func1,
|
||||
];
|
||||
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
additionalToolsRequireApproval ? func2 : new ApprovalRequiredAIFunction(func2),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
])
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedApprovalResponsesAreExecutedAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
]),
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedApprovalResponsesAreFailedAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Error: Function invocation approval was not granted.")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Error: Function invocation approval was not granted.")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted.")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> nonStreamingOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted.")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> streamingOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 3 } })]),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 3 } }))]),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlreadyExecutedApprovalsAreIgnoredAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]) { MessageId = "resp1" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func1")),
|
||||
]) { MessageId = "resp2" },
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func1")),
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "World"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]),
|
||||
new ChatMessage(ChatRole.Assistant, "World"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")),
|
||||
]) { MessageId = "resp1" },
|
||||
];
|
||||
|
||||
var invokeException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await InvokeAndAssertAsync(options, input, [], [], []));
|
||||
Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeException.Message);
|
||||
|
||||
var invokeStreamingException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await InvokeAndAssertStreamingAsync(options, input, [], [], []));
|
||||
Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeStreamingException.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedApprovalResponsesWithoutApprovalRequestAreExecutedAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FunctionCallContentIsNotPassedToDownstreamServiceWithServiceThreadsAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
],
|
||||
ConversationId = "test-conversation",
|
||||
};
|
||||
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new ChatMessage(ChatRole.User,
|
||||
[
|
||||
new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")),
|
||||
new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } }))
|
||||
]),
|
||||
];
|
||||
|
||||
List<ChatMessage> expectedDownstreamClientInput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
];
|
||||
|
||||
List<ChatMessage> downstreamClientOutput =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
List<ChatMessage> output =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]),
|
||||
new ChatMessage(ChatRole.Assistant, "world"),
|
||||
];
|
||||
|
||||
await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
|
||||
await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Since we do not have a way of supporting both functions that require approval and those that do not
|
||||
/// in one invocation, we always require all function calls to be approved if any require approval.
|
||||
/// If we are therefore unsure as to whether we will encounter a function call that requires approval,
|
||||
/// we have to wait until we find one before yielding any function call content.
|
||||
/// If we don't have any function calls that require approval at all though, we can just yield all content normally
|
||||
/// since this issue won't apply.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FunctionCallContentIsYieldedImmediatelyIfNoApprovalRequiredWhenStreamingAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];
|
||||
|
||||
Func<ChatClientBuilder, ChatClientBuilder> configurePipeline = b => b.Use(s => new NewFunctionInvokingChatClient(s));
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
var updateYieldCount = 0;
|
||||
|
||||
async IAsyncEnumerable<ChatResponseUpdate> YieldInnerClientUpdates(IEnumerable<ChatMessage> contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken)
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
await Task.Yield();
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = messageId };
|
||||
}
|
||||
|
||||
using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates };
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
|
||||
|
||||
var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
|
||||
|
||||
var updateCount = 0;
|
||||
await foreach (var update in updates)
|
||||
{
|
||||
if (updateCount < 2)
|
||||
{
|
||||
var functionCall = update.Contents.OfType<FunctionCallContent>().First();
|
||||
if (functionCall.CallId == "callId1")
|
||||
{
|
||||
Assert.Equal("Func1", functionCall.Name);
|
||||
Assert.Equal(1, updateYieldCount);
|
||||
}
|
||||
else if (functionCall.CallId == "callId2")
|
||||
{
|
||||
Assert.Equal("Func2", functionCall.Name);
|
||||
Assert.Equal(2, updateYieldCount);
|
||||
}
|
||||
}
|
||||
|
||||
updateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Since we do not have a way of supporting both functions that require approval and those that do not
|
||||
/// in one invocation, we always require all function calls to be approved if any require approval.
|
||||
/// If we are therefore unsure as to whether we will encounter a function call that requires approval,
|
||||
/// we have to wait until we find one before yielding any function call content.
|
||||
/// We can however, yield any other content until we encounter the first function call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FunctionCalsAreBufferedUntilApprovalRequirementEncounteredWhenStreamingAsync()
|
||||
{
|
||||
var options = new ChatOptions
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result 1", "Func1"),
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")),
|
||||
AIFunctionFactory.Create(() => "Result 3", "Func3"),
|
||||
]
|
||||
};
|
||||
|
||||
List<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];
|
||||
|
||||
Func<ChatClientBuilder, ChatClientBuilder> configurePipeline = b => b.Use(s => new NewFunctionInvokingChatClient(s));
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
var updateYieldCount = 0;
|
||||
|
||||
async IAsyncEnumerable<ChatResponseUpdate> YieldInnerClientUpdates(IEnumerable<ChatMessage> contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken)
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
await Task.Yield();
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 1")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 2")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary<string, object?> { { "i", 42 } })]) { MessageId = messageId };
|
||||
updateYieldCount++;
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func3")]) { MessageId = messageId };
|
||||
}
|
||||
|
||||
using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates };
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build();
|
||||
|
||||
var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
|
||||
|
||||
var updateCount = 0;
|
||||
await foreach (var update in updates)
|
||||
{
|
||||
switch (updateCount)
|
||||
{
|
||||
case 0:
|
||||
Assert.Equal("Text 1", update.Contents.OfType<TextContent>().First().Text);
|
||||
// First content should be yielded immedately, since we don't have any function calls yet.
|
||||
Assert.Equal(1, updateYieldCount);
|
||||
break;
|
||||
case 1:
|
||||
Assert.Equal("Text 2", update.Contents.OfType<TextContent>().First().Text);
|
||||
// Second content should be yielded immedately, since we don't have any function calls yet.
|
||||
Assert.Equal(2, updateYieldCount);
|
||||
break;
|
||||
case 2:
|
||||
var approvalRequest1 = update.Contents.OfType<FunctionApprovalRequestContent>().First();
|
||||
Assert.Equal("callId1", approvalRequest1.FunctionCall.CallId);
|
||||
Assert.Equal("Func1", approvalRequest1.FunctionCall.Name);
|
||||
// Third content should have been buffered, since we have not yet encountered a function call that requires approval.
|
||||
Assert.Equal(4, updateYieldCount);
|
||||
break;
|
||||
case 3:
|
||||
var approvalRequest2 = update.Contents.OfType<FunctionApprovalRequestContent>().First();
|
||||
Assert.Equal("callId2", approvalRequest2.FunctionCall.CallId);
|
||||
Assert.Equal("Func2", approvalRequest2.FunctionCall.Name);
|
||||
// Fourth content can be yielded immediately, since it is the first function call that requires approval.
|
||||
Assert.Equal(4, updateYieldCount);
|
||||
break;
|
||||
case 4:
|
||||
var approvalRequest3 = update.Contents.OfType<FunctionApprovalRequestContent>().First();
|
||||
Assert.Equal("callId1", approvalRequest3.FunctionCall.CallId);
|
||||
Assert.Equal("Func3", approvalRequest3.FunctionCall.Name);
|
||||
// Fifth content can be yielded immediately, since we previously encountered a function call that requires approval.
|
||||
Assert.Equal(5, updateYieldCount);
|
||||
break;
|
||||
}
|
||||
|
||||
updateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>> InvokeAndAssertAsync(
|
||||
ChatOptions? options,
|
||||
List<ChatMessage> input,
|
||||
List<ChatMessage> downstreamClientOutput,
|
||||
List<ChatMessage> expectedOutput,
|
||||
List<ChatMessage>? expectedDownstreamClientInput = null,
|
||||
Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
|
||||
AITool[]? additionalTools = null,
|
||||
IServiceProvider? services = null)
|
||||
{
|
||||
Assert.NotEmpty(input);
|
||||
|
||||
configurePipeline ??= b => b.Use(s => new NewFunctionInvokingChatClient(s) { AdditionalTools = additionalTools });
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
long expectedTotalTokenCounts = 0;
|
||||
|
||||
using var innerClient = new TestChatClient
|
||||
{
|
||||
GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) =>
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
if (expectedDownstreamClientInput is not null)
|
||||
{
|
||||
CompareMessageLists(expectedDownstreamClientInput, contents.ToList());
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
|
||||
var usage = CreateRandomUsage();
|
||||
expectedTotalTokenCounts += usage.InputTokenCount!.Value;
|
||||
|
||||
downstreamClientOutput.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N"));
|
||||
return new ChatResponse(downstreamClientOutput) { Usage = usage };
|
||||
}
|
||||
};
|
||||
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services);
|
||||
|
||||
var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token);
|
||||
Assert.NotNull(result);
|
||||
|
||||
var actualOutput = result.Messages as List<ChatMessage> ?? result.Messages.ToList();
|
||||
CompareMessageLists(expectedOutput, actualOutput);
|
||||
|
||||
// Usage should be aggregated over all responses, including AdditionalUsage
|
||||
var actualUsage = result.Usage!;
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.InputTokenCount);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.OutputTokenCount);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.TotalTokenCount);
|
||||
Assert.Equal(2, actualUsage.AdditionalCounts!.Count);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["firstValue"]);
|
||||
Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["secondValue"]);
|
||||
|
||||
return actualOutput;
|
||||
}
|
||||
|
||||
private static UsageDetails CreateRandomUsage()
|
||||
{
|
||||
// We'll set the same random number on all the properties so that, when determining the
|
||||
// correct sum in tests, we only have to total the values once
|
||||
var value = new Random().Next(100);
|
||||
return new UsageDetails
|
||||
{
|
||||
InputTokenCount = value,
|
||||
OutputTokenCount = value,
|
||||
TotalTokenCount = value,
|
||||
AdditionalCounts = new() { ["firstValue"] = value, ["secondValue"] = value },
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>> InvokeAndAssertStreamingAsync(
|
||||
ChatOptions? options,
|
||||
List<ChatMessage> input,
|
||||
List<ChatMessage> downstreamClientOutput,
|
||||
List<ChatMessage> expectedOutput,
|
||||
List<ChatMessage>? expectedDownstreamClientInput = null,
|
||||
Func<ChatClientBuilder, ChatClientBuilder>? configurePipeline = null,
|
||||
AITool[]? additionalTools = null,
|
||||
IServiceProvider? services = null)
|
||||
{
|
||||
Assert.NotEmpty(input);
|
||||
|
||||
configurePipeline ??= b => b.Use(s => new NewFunctionInvokingChatClient(s) { AdditionalTools = additionalTools });
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
using var innerClient = new TestChatClient
|
||||
{
|
||||
GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) =>
|
||||
{
|
||||
Assert.Equal(cts.Token, actualCancellationToken);
|
||||
if (expectedDownstreamClientInput is not null)
|
||||
{
|
||||
CompareMessageLists(expectedDownstreamClientInput, contents.ToList());
|
||||
}
|
||||
|
||||
downstreamClientOutput.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N"));
|
||||
return YieldAsync(new ChatResponse(downstreamClientOutput).ToChatResponseUpdates());
|
||||
}
|
||||
};
|
||||
|
||||
IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services);
|
||||
|
||||
var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable<ChatMessage>(input), options, cts.Token).ToChatResponseAsync();
|
||||
Assert.NotNull(result);
|
||||
|
||||
var actualOutput = result.Messages as List<ChatMessage> ?? result.Messages.ToList();
|
||||
|
||||
expectedOutput ??= input;
|
||||
CompareMessageLists(expectedOutput, actualOutput);
|
||||
|
||||
return actualOutput;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> YieldAsync<T>(params T[] items)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareMessageLists(List<ChatMessage> expectedMessages, List<ChatMessage> actualMessages)
|
||||
{
|
||||
Assert.Equal(expectedMessages.Count, actualMessages.Count);
|
||||
for (int i = 0; i < expectedMessages.Count; i++)
|
||||
{
|
||||
var expectedMessage = expectedMessages[i];
|
||||
var chatMessage = actualMessages[i];
|
||||
|
||||
Assert.Equal(expectedMessage.Role, chatMessage.Role);
|
||||
Assert.Equal(expectedMessage.Text, chatMessage.Text);
|
||||
Assert.Equal(expectedMessage.GetType(), chatMessage.GetType());
|
||||
|
||||
Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count);
|
||||
for (int j = 0; j < expectedMessage.Contents.Count; j++)
|
||||
{
|
||||
var expectedItem = expectedMessage.Contents[j];
|
||||
var chatItem = chatMessage.Contents[j];
|
||||
|
||||
Assert.Equal(expectedItem.GetType(), chatItem.GetType());
|
||||
Assert.Equal(expectedItem.ToString(), chatItem.ToString());
|
||||
if (expectedItem is FunctionCallContent expectedFunctionCall)
|
||||
{
|
||||
var chatFunctionCall = (FunctionCallContent)chatItem;
|
||||
Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name);
|
||||
AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments);
|
||||
}
|
||||
else if (expectedItem is FunctionResultContent expectedFunctionResult)
|
||||
{
|
||||
var chatFunctionResult = (FunctionResultContent)chatItem;
|
||||
AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EnumeratedOnceEnumerable<T>(IEnumerable<T> items) : IEnumerable<T>
|
||||
{
|
||||
private int _iterated;
|
||||
|
||||
public IEnumerator<T> GetEnumerator()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _iterated, 1) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("This enumerable can only be enumerated once.");
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// WARNING:
|
||||
// This class has been temporarily copied here from MEAI, to allow prototyping
|
||||
// functionality that will be moved to MEAI in the future.
|
||||
// This file is not intended to be modified.
|
||||
|
||||
// AF repo suppressions for code copied from MEAI.
|
||||
#pragma warning disable IDE0009 // Member access should be qualified.
|
||||
#pragma warning disable CA1859 // Use concrete types when possible for improved performance
|
||||
#pragma warning disable CA1063 // Implement IDisposable Correctly
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
public sealed class TestChatClient : IChatClient
|
||||
{
|
||||
public TestChatClient()
|
||||
{
|
||||
GetServiceCallback = DefaultGetServiceCallback;
|
||||
}
|
||||
|
||||
public IServiceProvider? Services { get; set; }
|
||||
|
||||
public Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>>? GetResponseAsyncCallback { get; set; }
|
||||
|
||||
public Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>>? GetStreamingResponseAsyncCallback { get; set; }
|
||||
|
||||
public Func<Type, object?, object?> GetServiceCallback { get; set; }
|
||||
|
||||
private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) =>
|
||||
serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> GetResponseAsyncCallback!.Invoke(messages, options, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> GetStreamingResponseAsyncCallback!.Invoke(messages, options, cancellationToken);
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
=> GetServiceCallback(serviceType, serviceKey);
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// No resources need disposing.
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
|
||||
IList<AITool>? aiTools = null)
|
||||
{
|
||||
return Task.FromResult(new ChatClientAgent(
|
||||
this._openAIResponseClient.AsNewIChatClient(),
|
||||
this._openAIResponseClient.AsIChatClient(),
|
||||
options: new()
|
||||
{
|
||||
Name = name,
|
||||
|
||||
Reference in New Issue
Block a user