From 2f2a3fd4a48dbf36931578981cd94dd02787177e Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 5 Jun 2025 00:48:23 +0000 Subject: [PATCH 1/4] Merged PR 50646: Getting ready for the 9.6 Release Getting ready for th 9.6 release ---- #### AI description (iteration 1) #### PR Classification This PR prepares the repository for the 9.6 release by upgrading dependency versions and refining build pipeline configurations. #### PR Summary The PR updates key dependency versions and adjusts pipeline settings to support a stable 9.6 release build. - `/eng/Version.Details.xml`: Upgraded dependency versions from 9.0.5 to 9.0.6 with corresponding SHA updates. - `/eng/Versions.props`: Updated version properties for major dependencies (and LTS versions bumped to 8.0.17) while enabling package stabilization and setting DotNetFinalVersionKind to release. - `/NuGet.config`: Revised package source definitions and disabled package source mappings for internal feeds. - `/azure-pipelines.yml` & `/eng/pipelines/templates/BuildAndTest.yml`: Removed the CodeCoverage stage and added tasks to set up private feed credentials with integration tests temporarily skipped. - `/Directory.Build.props`: Suppressed the NU1507 warning for internal branch builds. --- Directory.Build.props | 5 + NuGet.config | 64 +++++--- azure-pipelines.yml | 46 ------ eng/Version.Details.xml | 188 +++++++++++------------ eng/Versions.props | 122 +++++++-------- eng/pipelines/templates/BuildAndTest.yml | 32 +++- 6 files changed, 227 insertions(+), 230 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0af806af628..0c0fcf22bfd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -34,6 +34,11 @@ $(NetCoreTargetFrameworks) + + + $(NoWarn);NU1507 + + false latest diff --git a/NuGet.config b/NuGet.config index 0fedd015e82..3df29b93f5a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -4,10 +4,31 @@ + + + + + + + + + + + + + + + + + + + + + @@ -18,35 +39,34 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3ec5e3d1cdb..0052dc9f706 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -239,51 +239,6 @@ extends: isWindows: false warnAsError: 0 - # ---------------------------------------------------------------- - # This stage performs quality gates enforcements - # ---------------------------------------------------------------- - - stage: codecoverage - displayName: CodeCoverage - dependsOn: - - build - condition: and(succeeded('build'), ne(variables['SkipQualityGates'], 'true')) - variables: - - template: /eng/common/templates-official/variables/pool-providers.yml@self - jobs: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - enableMicrobuild: true - enableTelemetry: true - runAsPublic: ${{ variables['runAsPublic'] }} - workspace: - clean: all - - # ---------------------------------------------------------------- - # This stage downloads the code coverage reports from the build jobs, - # merges those and validates the combined test coverage. - # ---------------------------------------------------------------- - jobs: - - job: CodeCoverageReport - timeoutInMinutes: 180 - - pool: - name: NetCore1ESPool-Internal - image: 1es-mariner-2 - os: linux - - preSteps: - - checkout: self - clean: true - persistCredentials: true - fetchDepth: 1 - - steps: - - script: $(Build.SourcesDirectory)/build.sh --ci --restore - displayName: Init toolset - - - template: /eng/pipelines/templates/VerifyCoverageReport.yml - - # ---------------------------------------------------------------- # This stage only performs a build treating warnings as errors # to detect any kind of code style violations @@ -339,7 +294,6 @@ extends: parameters: validateDependsOn: - build - - codecoverage - correctness publishingInfraVersion: 3 enableSymbolValidation: false diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index ac0208aebcd..427a514700a 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,196 +1,196 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + 3875b54e7b10b10606b105340199946d0b877754 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - 6765359588e8b38bab2a7974db9398432703828f + 8751e6d519fda94d5154187358765311ed4a4e84 diff --git a/eng/Versions.props b/eng/Versions.props index 483b6366af6..690e6d32f2d 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -11,14 +11,14 @@ - false + true - + release true @@ -34,55 +34,55 @@ --> - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 + 9.0.6 - 9.0.5 + 9.0.6 9.0.0-beta.25271.1 @@ -108,8 +108,8 @@ 8.0.1 8.0.0 8.0.2 - 8.0.16 - 8.0.16 + 8.0.17 + 8.0.17 8.0.0 8.0.1 8.0.1 @@ -126,17 +126,17 @@ 8.0.5 8.0.0 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 + 8.0.17 + 8.0.17 + 8.0.17 + 8.0.17 + 8.0.17 + 8.0.17 + 8.0.17 + 8.0.17 + 8.0.17 - 8.0.16 + 8.0.17 --- .../OpenAIAssistantChatClient.cs | 446 ++++++++++++++++++ .../OpenAIChatClient.cs | 16 +- .../OpenAIClientExtensions.cs | 14 + .../OpenAIResponseChatClient.cs | 118 +++-- .../ChatClientIntegrationTests.cs | 2 +- .../QuantizationEmbeddingGenerator.cs | 2 +- ...enAIAssistantChatClientIntegrationTests.cs | 83 ++++ .../OpenAIAssistantChatClientTests.cs | 77 +++ .../OpenAIResponseClientIntegrationTests.cs | 5 + .../OpenAIResponseClientTests.cs | 11 +- 10 files changed, 728 insertions(+), 46 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs new file mode 100644 index 00000000000..c3aab83da61 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs @@ -0,0 +1,446 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +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; + +/// Represents an for an Azure.AI.Agents.Persistent . +[Experimental("OPENAI001")] +internal sealed partial class OpenAIAssistantChatClient : IChatClient +{ + /// The underlying . + private readonly AssistantClient _client; + + /// Metadata for the client. + private readonly ChatClientMetadata _metadata; + + /// The ID of the agent to use. + private readonly string _assistantId; + + /// The thread ID to use if none is supplied in . + private readonly string? _defaultThreadId; + + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantChatClient(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 ?? OpenAIResponseChatClient.DefaultOpenAIEndpoint; + + _metadata = new("openai", providerUrl); + } + + /// + 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; + + /// + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken); + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Extract necessary state from messages and options. + (RunCreationOptions runOptions, List? toolResults) = CreateRunOptions(messages, options); + + // 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 updates; + if (threadRun is not null && + ConvertFunctionResultsToToolOutput(toolResults, out List? 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(); + 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) + { + ruUpdate.Contents.Add( + new FunctionCallContent( + JsonSerializer.Serialize([ru.Value.Id, toolCallId], AssistantJsonContext.Default.StringArray), + functionName, + JsonSerializer.Deserialize(rau.FunctionArguments, AssistantJsonContext.Default.IDictionaryStringObject)!)); + } + + yield return ruUpdate; + break; + + case MessageContentUpdate mcu: + yield return new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = mcu, + ResponseId = responseId, + }; + break; + + default: + yield return new ChatResponseUpdate + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + Role = ChatRole.Assistant, + }; + break; + } + } + } + + /// + void IDisposable.Dispose() + { + // nop + } + + /// + /// Creates the to use for the request and extracts any function result contents + /// that need to be submitted as tool results. + /// + private (RunCreationOptions RunOptions, List? ToolResults) CreateRunOptions( + IEnumerable messages, ChatOptions? options) + { + // Create the options instance to populate, either a fresh or using one the caller provides. + RunCreationOptions runOptions = + options?.RawRepresentationFactory?.Invoke(this) as RunCreationOptions ?? + new(); + + // 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) + { + // 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: + bool? strict = aiFunction.AdditionalProperties.TryGetValue(nameof(strict), out var strictValue) && strictValue is bool strictBool ? + strictBool : + null; + runOptions.ToolsOverride.Add(new FunctionToolDefinition(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(aiFunction.JsonSchema, AssistantJsonContext.Default.JsonElement)), + StrictParameterSchemaEnabled = strict, + }); + break; + + case HostedCodeInterpreterTool: + runOptions.ToolsOverride.Add(new CodeInterpreterToolDefinition()); + break; + } + } + } + + // Store the tool mode, if relevant. + if (runOptions.ToolConstraint is null) + { + switch (options.ToolMode) + { + case NoneChatToolMode: + runOptions.ToolConstraint = ToolConstraint.None; + break; + + case null: + 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 jsonFormat.Schema is not null: + runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonFormat.Schema, AssistantJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription); + break; + + case ChatResponseFormatJson jsonFormat: + runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonObjectFormat(); + break; + } + } + } + + // Process ChatMessages. + StringBuilder? instructions = null; + List? functionResults = null; + foreach (var chatMessage in messages) + { + List 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 == OpenAIResponseChatClient.ChatRoleDeveloper) + { + instructions ??= new(); + foreach (var textContent in chatMessage.Contents.OfType()) + { + _ = instructions.Append(textContent); + } + + continue; + } + + foreach (AIContent content in chatMessage.Contents) + { + switch (content) + { + case TextContent text: + messageContents.Add(MessageContent.FromText(text.Text)); + break; + + case UriContent image when image.HasTopLevelMediaType("image"): + messageContents.Add(MessageContent.FromImageUri(image.Uri)); + break; + + // Assistants doesn't support data URIs. + //case DataContent image when image.HasTopLevelMediaType("image"): + // messageContents.Add(MessageContent.FromImageUri(new Uri(image.Uri))); + // break; + + case FunctionResultContent result: + (functionResults ??= []).Add(result); + break; + + case AIContent when content.RawRepresentation is MessageContent rawRep: + messageContents.Add(rawRep); + break; + } + } + + if (messageContents.Count > 0) + { + runOptions.AdditionalMessages.Add(new ThreadInitializationMessage( + chatMessage.Role == ChatRole.Assistant ? MessageRole.Assistant : MessageRole.User, + messageContents)); + } + } + + if (instructions is not null) + { + runOptions.AdditionalInstructions = instructions.ToString(); + } + + return (runOptions, functionResults); + } + + /// Convert instances to instances. + /// The tool results to process. + /// The generated list of tool outputs, if any could be created. + /// The run ID associated with the corresponding function call requests. + private static string? ConvertFunctionResultsToToolOutput(List? toolResults, out List? 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, AssistantJsonContext.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; + } + + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(string[]))] + [JsonSerializable(typeof(IDictionary))] + private sealed partial class AssistantJsonContext : JsonSerializerContext; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 001f4d1a593..f97ebd492a7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -34,9 +34,6 @@ internal sealed partial class OpenAIChatClient : IChatClient MoveDefaultKeywordToDescription = true, }); - /// Gets the default OpenAI endpoint. - private static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); - /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -57,7 +54,7 @@ public OpenAIChatClient(ChatClient chatClient) // 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(ChatClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(chatClient) as Uri ?? DefaultOpenAIEndpoint; + ?.GetValue(chatClient) as Uri ?? OpenAIResponseChatClient.DefaultOpenAIEndpoint; string? model = typeof(ChatClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(chatClient) as string; @@ -113,8 +110,6 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } - private static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); - /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, JsonSerializerOptions options) { @@ -125,12 +120,12 @@ void IDisposable.Dispose() { if (input.Role == ChatRole.System || input.Role == ChatRole.User || - input.Role == ChatRoleDeveloper) + input.Role == OpenAIResponseChatClient.ChatRoleDeveloper) { var parts = ToOpenAIChatContent(input.Contents); yield return input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : - input.Role == ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } : + input.Role == OpenAIResponseChatClient.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } : new UserChatMessage(parts) { ParticipantName = input.AuthorName }; } else if (input.Role == ChatRole.Tool) @@ -261,6 +256,9 @@ private static List ToOpenAIChatContent(IList case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, $"{Guid.NewGuid():N}.pdf"); + + case AIContent when content.RawRepresentation is ChatMessageContentPart rawContentPart: + return rawContentPart; } return null; @@ -619,7 +617,7 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => ChatMessageRole.User => ChatRole.User, ChatMessageRole.Assistant => ChatRole.Assistant, ChatMessageRole.Tool => ChatRole.Tool, - ChatMessageRole.Developer => ChatRoleDeveloper, + ChatMessageRole.Developer => OpenAIResponseChatClient.ChatRoleDeveloper, _ => new ChatRole(role.ToString()), }; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 81d2fe55a03..ea43b7e5e31 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using OpenAI; +using OpenAI.Assistants; using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; @@ -25,6 +26,19 @@ public static IChatClient AsIChatClient(this ChatClient chatClient) => public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => new OpenAIResponseChatClient(responseClient); + /// Gets an for use with this . + /// The instance to be accessed as an . + /// The unique identifier of the assistant with which to interact. + /// + /// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to + /// or via the + /// property. If no thread ID is provided via either mechanism, a new thread will be created for the request. + /// + /// An instance configured to interact with the specified agent and thread. + [Experimental("OPENAI001")] + public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => + new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); + /// Gets an for use with this . /// The client. /// An that can be used to transcribe audio via the . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 25035e7e225..34e6977e1f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -15,6 +15,7 @@ using OpenAI.Responses; using static Microsoft.Extensions.AI.OpenAIChatClient; +#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 @@ -26,10 +27,10 @@ namespace Microsoft.Extensions.AI; internal sealed partial class OpenAIResponseChatClient : IChatClient { /// Gets the default OpenAI endpoint. - private static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); + internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); - /// A for "developer". - private static readonly ChatRole _chatRoleDeveloper = new("developer"); + /// Gets a for "developer". + internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -87,12 +88,13 @@ public async Task GetResponseAsync( // Convert and return the results. ChatResponse response = new() { - ResponseId = openAIResponse.Id, - ConversationId = openAIResponse.Id, + ConversationId = openAIOptions.StoredOutputEnabled is false ? null : openAIResponse.Id, CreatedAt = openAIResponse.CreatedAt, FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), Messages = [new(ChatRole.Assistant, [])], ModelId = openAIResponse.Model, + RawRepresentation = openAIResponse, + ResponseId = openAIResponse.Id, Usage = ToUsageDetails(openAIResponse), }; @@ -125,12 +127,20 @@ public async Task GetResponseAsync( case FunctionCallResponseItem functionCall: response.FinishReason ??= ChatFinishReason.ToolCalls; - message.Contents.Add( - FunctionCallContent.CreateFromParsedArguments( - functionCall.FunctionArguments.ToMemory(), - functionCall.CallId, - functionCall.FunctionName, - static json => JsonSerializer.Deserialize(json.Span, ResponseClientJsonContext.Default.IDictionaryStringObject)!)); + var fcc = FunctionCallContent.CreateFromParsedArguments( + functionCall.FunctionArguments.ToMemory(), + functionCall.CallId, + functionCall.FunctionName, + static json => JsonSerializer.Deserialize(json.Span, ResponseClientJsonContext.Default.IDictionaryStringObject)!); + fcc.RawRepresentation = outputItem; + message.Contents.Add(fcc); + break; + + default: + message.Contents.Add(new() + { + RawRepresentation = outputItem, + }); break; } } @@ -157,6 +167,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( // Make the call to the OpenAIResponseClient and process the streaming results. DateTimeOffset? createdAt = null; string? responseId = null; + string? conversationId = null; string? modelId = null; string? lastMessageId = null; ChatRole? lastRole = null; @@ -169,21 +180,23 @@ public async IAsyncEnumerable GetStreamingResponseAsync( case StreamingResponseCreatedUpdate createdUpdate: createdAt = createdUpdate.Response.CreatedAt; responseId = createdUpdate.Response.Id; + conversationId = openAIOptions.StoredOutputEnabled is false ? null : responseId; modelId = createdUpdate.Response.Model; - break; + goto default; case StreamingResponseCompletedUpdate completedUpdate: yield return new() { Contents = ToUsageDetails(completedUpdate.Response) is { } usage ? [new UsageContent(usage)] : [], + ConversationId = conversationId, CreatedAt = createdAt, - ResponseId = responseId, - ConversationId = responseId, FinishReason = ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? (functionCallInfos is not null ? ChatFinishReason.ToolCalls : ChatFinishReason.Stop), MessageId = lastMessageId, ModelId = modelId, + RawRepresentation = streamingUpdate, + ResponseId = responseId, Role = lastRole, }; break; @@ -200,11 +213,11 @@ public async IAsyncEnumerable GetStreamingResponseAsync( break; } - break; + goto default; case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate: _ = outputIndexToMessages.Remove(outputItemDoneUpdate.OutputIndex); - break; + goto default; case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate: _ = outputIndexToMessages.TryGetValue(outputTextDeltaUpdate.OutputIndex, out MessageResponseItem? messageItem); @@ -212,11 +225,12 @@ public async IAsyncEnumerable GetStreamingResponseAsync( lastRole = ToChatRole(messageItem?.Role); yield return new ChatResponseUpdate(lastRole, outputTextDeltaUpdate.Delta) { + ConversationId = conversationId, CreatedAt = createdAt, MessageId = lastMessageId, ModelId = modelId, + RawRepresentation = streamingUpdate, ResponseId = responseId, - ConversationId = responseId, }; break; @@ -227,7 +241,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( _ = (callInfo.Arguments ??= new()).Append(functionCallArgumentsDeltaUpdate.Delta); } - break; + goto default; } case StreamingResponseFunctionCallArgumentsDoneUpdate functionCallOutputDoneUpdate: @@ -246,26 +260,23 @@ public async IAsyncEnumerable GetStreamingResponseAsync( lastRole = ChatRole.Assistant; yield return new ChatResponseUpdate(lastRole, [fci]) { + ConversationId = conversationId, CreatedAt = createdAt, MessageId = lastMessageId, ModelId = modelId, + RawRepresentation = streamingUpdate, ResponseId = responseId, - ConversationId = responseId, }; + + break; } - break; + goto default; } case StreamingResponseErrorUpdate errorUpdate: yield return new ChatResponseUpdate { - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - ResponseId = responseId, - Role = lastRole, - ConversationId = responseId, Contents = [ new ErrorContent(errorUpdate.Message) @@ -274,19 +285,40 @@ public async IAsyncEnumerable GetStreamingResponseAsync( Details = errorUpdate.Param, } ], + ConversationId = conversationId, + CreatedAt = createdAt, + MessageId = lastMessageId, + ModelId = modelId, + RawRepresentation = streamingUpdate, + ResponseId = responseId, + Role = lastRole, }; break; case StreamingResponseRefusalDoneUpdate refusalDone: yield return new ChatResponseUpdate { + Contents = [new ErrorContent(refusalDone.Refusal) { ErrorCode = nameof(ResponseContentPart.Refusal) }], + ConversationId = conversationId, CreatedAt = createdAt, MessageId = lastMessageId, ModelId = modelId, + RawRepresentation = streamingUpdate, + ResponseId = responseId, + Role = lastRole, + }; + break; + + default: + yield return new ChatResponseUpdate + { + ConversationId = conversationId, + CreatedAt = createdAt, + MessageId = lastMessageId, + ModelId = modelId, + RawRepresentation = streamingUpdate, ResponseId = responseId, Role = lastRole, - ConversationId = responseId, - Contents = [new ErrorContent(refusalDone.Refusal) { ErrorCode = nameof(ResponseContentPart.Refusal) }], }; break; } @@ -304,7 +336,7 @@ private static ChatRole ToChatRole(MessageRole? role) => role switch { MessageRole.System => ChatRole.System, - MessageRole.Developer => _chatRoleDeveloper, + MessageRole.Developer => ChatRoleDeveloper, MessageRole.User => ChatRole.User, _ => ChatRole.Assistant, }; @@ -422,7 +454,7 @@ private static IEnumerable ToOpenAIResponseItems( foreach (ChatMessage input in inputs) { if (input.Role == ChatRole.System || - input.Role == _chatRoleDeveloper) + input.Role == ChatRoleDeveloper) { string text = input.Text; if (!string.IsNullOrWhiteSpace(text)) @@ -487,6 +519,10 @@ private static IEnumerable ToOpenAIResponseItems( callContent.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); break; + + case AIContent when item.RawRepresentation is ResponseItem rawRep: + yield return rawRep; + break; } } @@ -530,11 +566,25 @@ private static List ToAIContents(IEnumerable con switch (part.Kind) { case ResponseContentPartKind.OutputText: - results.Add(new TextContent(part.Text)); + results.Add(new TextContent(part.Text) + { + RawRepresentation = part, + }); break; case ResponseContentPartKind.Refusal: - results.Add(new ErrorContent(part.Refusal) { ErrorCode = nameof(ResponseContentPartKind.Refusal) }); + results.Add(new ErrorContent(part.Refusal) + { + ErrorCode = nameof(ResponseContentPartKind.Refusal), + RawRepresentation = part, + }); + break; + + default: + results.Add(new() + { + RawRepresentation = part, + }); break; } } @@ -570,6 +620,10 @@ private static List ToOpenAIResponsesContent(IList false; diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs index ea87408da38..5a7bf0b246e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs @@ -52,7 +52,7 @@ private static BinaryEmbedding QuantizeToBinary(Embedding embedding) { if (vector[i] > 0) { - result[i / 8] = true; + result[i] = true; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs new file mode 100644 index 00000000000..e616d5fb87b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable CA1822 // Mark members as static +#pragma warning disable CA2000 // Dispose objects before losing scope +#pragma warning disable S1135 // Track uses of "TODO" tags +#pragma warning disable xUnit1013 // Public method should be marked as test + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using OpenAI.Assistants; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIAssistantChatClientIntegrationTests : ChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() + { + var openAIClient = IntegrationTestHelpers.GetOpenAIClient(); + if (openAIClient is null) + { + return null; + } + + AssistantClient ac = openAIClient.GetAssistantClient(); + var assistant = + ac.GetAssistants().FirstOrDefault() ?? + ac.CreateAssistant("gpt-4o-mini"); + + return ac.AsIChatClient(assistant.Id); + } + + public override bool FunctionInvokingChatClientSetsConversationId => true; + + // These tests aren't written in a way that works well with threads. + public override Task Caching_AfterFunctionInvocation_FunctionOutputChangedAsync() => Task.CompletedTask; + public override Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() => Task.CompletedTask; + + // Assistants doesn't support data URIs. + public override Task MultiModal_DescribeImage() => Task.CompletedTask; + public override Task MultiModal_DescribePdf() => Task.CompletedTask; + + // [Fact] // uncomment and run to clear out _all_ threads in your OpenAI account + public async Task DeleteAllThreads() + { + using HttpClient client = new(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }); + + // These values need to be filled in. The bearer token needs to be sniffed from a browser + // session interacting with the dashboard (e.g. use F12 networking tools to look at request headers + // made to "https://api.openai.com/v1/threads?limit=10" after clicking on Assistants | Threads in the + // OpenAI portal dashboard). + client.DefaultRequestHeaders.Add("authorization", $"Bearer sess-ENTERYOURSESSIONTOKEN"); + client.DefaultRequestHeaders.Add("openai-organization", "org-ENTERYOURORGID"); + client.DefaultRequestHeaders.Add("openai-project", "proj_ENTERYOURPROJECTID"); + + AssistantClient ac = new AssistantClient(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); + while (true) + { + string listing = await client.GetStringAsync("https://api.openai.com/v1/threads?limit=100"); + + var matches = Regex.Matches(listing, @"thread_\w+"); + if (matches.Count == 0) + { + break; + } + + foreach (Match m in matches) + { + var dr = await ac.DeleteThreadAsync(m.Value); + Assert.True(dr.Value.Deleted); + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs new file mode 100644 index 00000000000..6d3a02a08ec --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using Azure.AI.OpenAI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using OpenAI; +using OpenAI.Assistants; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +namespace Microsoft.Extensions.AI; + +public class OpenAIAssistantChatClientTests +{ + [Fact] + public void AsIChatClient_InvalidArgs_Throws() + { + Assert.Throws("assistantClient", () => ((AssistantClient)null!).AsIChatClient("assistantId")); + Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + { + Uri endpoint = new("http://localhost/some/endpoint"); + + var client = useAzureOpenAI ? + new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : + new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IChatClient[] clients = + [ + client.GetAssistantClient().AsIChatClient("assistantId"), + client.GetAssistantClient().AsIChatClient("assistantId", "threadId"), + ]; + + foreach (var chatClient in clients) + { + var metadata = chatClient.GetService(); + Assert.Equal("openai", metadata?.ProviderName); + Assert.Equal(endpoint, metadata?.ProviderUri); + } + } + + [Fact] + public void GetService_AssistantClient_SuccessfullyReturnsUnderlyingClient() + { + AssistantClient assistantClient = new OpenAIClient("key").GetAssistantClient(); + IChatClient chatClient = assistantClient.AsIChatClient("assistantId"); + + Assert.Same(assistantClient, chatClient.GetService()); + + Assert.Null(chatClient.GetService()); + + using IChatClient pipeline = chatClient + .AsBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Build(); + + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + + Assert.Same(assistantClient, pipeline.GetService()); + Assert.IsType(pipeline.GetService()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 2c1d6cdc80e..f8e835bdb81 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Threading.Tasks; + namespace Microsoft.Extensions.AI; public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests @@ -11,4 +13,7 @@ public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests .AsIChatClient(); public override bool FunctionInvokingChatClientSetsConversationId => true; + + // Test structure doesn't make sense with Respones. + public override Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() => Task.CompletedTask; } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 3747b79dc88..8b27cd918a7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -264,19 +264,24 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal("Hello! How can I assist you today?", string.Concat(updates.Select(u => u.Text))); var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_741_892_091); - Assert.Equal(10, updates.Count); + Assert.Equal(17, updates.Count); + for (int i = 0; i < updates.Count; i++) { Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ConversationId); Assert.Equal(createdAt, updates[i].CreatedAt); Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); - Assert.Equal(ChatRole.Assistant, updates[i].Role); Assert.Null(updates[i].AdditionalProperties); - Assert.Equal(i == 10 ? 0 : 1, updates[i].Contents.Count); + Assert.Equal((i >= 4 && i <= 12) || i == 16 ? 1 : 0, updates[i].Contents.Count); Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Stop, updates[i].FinishReason); } + for (int i = 4; i < updates.Count; i++) + { + Assert.Equal(ChatRole.Assistant, updates[i].Role); + } + UsageContent usage = updates.SelectMany(u => u.Contents).OfType().Single(); Assert.Equal(26, usage.Details.InputTokenCount); Assert.Equal(10, usage.Details.OutputTokenCount); From 2481e9e72251b7b5658a32c7e2ad4cc1d4c3bf8d Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 10 Jun 2025 15:49:15 -0700 Subject: [PATCH 4/4] Fix template tests --- src/ProjectTemplates/GeneratedContent.targets | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index c3287408034..4f3e918a77f 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -20,9 +20,9 @@ - Use specific version numbers to pin to already-released packages --> - $(Version) - $(Version) - $(Version) + 9.6.0 + 9.6.0-preview.1.25310.2 + 9.6.0