diff --git a/eng/packages/General.props b/eng/packages/General.props index b7066789238..5be4031ad4d 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -16,7 +16,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index a50aea8dea3..8c1febc6bc8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -2,12 +2,13 @@ ## NOT YET RELEASED +- Updated to depend on OpenAI 2.5.0. - Added M.E.AI to OpenAI conversions for response format types. - Added `ResponseTool` to `AITool` conversions. ## 9.9.0-preview.1.25458.4 -- Updated to depend on OpenAI 2.4.0 +- Updated to depend on OpenAI 2.4.0. - Updated tool mappings to recognize any `AIFunctionDeclaration`. - Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. - Fixed handling of annotated but empty content in the `AsIChatClient` for `AssistantClient`. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index dbe9bf10237..9c3da231065 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -11,12 +14,25 @@ #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 EA0011 // Consider removing unnecessary conditional access operator (?) namespace Microsoft.Extensions.AI; /// An for an OpenAI . internal sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator> { + // This delegate instance is used to call the internal overload of GenerateEmbeddingsAsync that accepts + // a RequestOptions. This should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>? + _generateEmbeddingsAsync = + (Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>?) + typeof(EmbeddingClient) + .GetMethod( + nameof(EmbeddingClient.GenerateEmbeddingsAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(OpenAI.Embeddings.EmbeddingGenerationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>)); + /// Metadata about the embedding generator. private readonly EmbeddingGeneratorMetadata _metadata; @@ -49,7 +65,10 @@ public async Task>> GenerateAsync(IEnumerab { OpenAI.Embeddings.EmbeddingGenerationOptions? openAIOptions = ToOpenAIOptions(options); - var embeddings = (await _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + var t = _generateEmbeddingsAsync is not null ? + _generateEmbeddingsAsync(_embeddingClient, values, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken); + var embeddings = (await t.ConfigureAwait(false)).Value; return new(embeddings.Select(e => new Embedding(e.ToFloats()) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index d094f5f8581..ec662a68891 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -65,12 +65,7 @@ public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) _responseClient = responseClient; - // https://github.com/openai/openai-dotnet/issues/662 - // Update to avoid reflection once OpenAIResponseClient.Model is exposed publicly. - string? model = typeof(OpenAIResponseClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(responseClient) as string; - - _metadata = new("openai", responseClient.Endpoint, model); + _metadata = new("openai", responseClient.Endpoint, responseClient.Model); } /// @@ -469,27 +464,19 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt break; case HostedCodeInterpreterTool codeTool: - string json; - if (codeTool.Inputs is { Count: > 0 } inputs) - { - string jsonArray = JsonSerializer.Serialize( - inputs.OfType().Select(c => c.FileId), - OpenAIJsonContext.Default.IEnumerableString); - json = $$"""{"type":"code_interpreter","container":{"type":"auto",files:{{jsonArray}}} }"""; - } - else - { - json = """{"type":"code_interpreter","container":{"type":"auto"}}"""; - } - - result.Tools.Add(ModelReaderWriter.Read(BinaryData.FromString(json))); + result.Tools.Add( + ResponseTool.CreateCodeInterpreterTool( + new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? + CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(ids) : + new()))); break; case HostedMcpServerTool mcpTool: McpTool responsesMcpTool = ResponseTool.CreateMcpTool( mcpTool.ServerName, mcpTool.Url, - mcpTool.Headers); + serverDescription: mcpTool.ServerDescription, + headers: mcpTool.Headers); if (mcpTool.AllowedTools is not null) { @@ -673,8 +660,8 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable(() => client.GetResponseAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) .GetChatClient(modelId) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 43112fa88e3..0db88d499e1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -125,10 +125,7 @@ public async Task GetEmbeddingsAsync_ExpectedRequestResponse() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using IEmbeddingGenerator> generator = new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions - { - Transport = new HttpClientPipelineTransport(httpClient), - }).GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); var response = await generator.GenerateAsync([ "hello, world!", @@ -188,10 +185,7 @@ public async Task EmbeddingGenerationOptions_DoNotOverwrite_NotNullPropertiesInR using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using IEmbeddingGenerator> generator = new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions - { - Transport = new HttpClientPipelineTransport(httpClient), - }).GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); var response = await generator.GenerateAsync([ "hello, world!", @@ -221,4 +215,24 @@ public async Task EmbeddingGenerationOptions_DoNotOverwrite_NotNullPropertiesInR Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); } } + + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => generator.GenerateAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + + private static IEmbeddingGenerator> CreateEmbeddingGenerator(HttpClient httpClient, string modelId) => + new OpenAIClient( + new ApiKeyCredential("apikey"), + new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetEmbeddingClient(modelId) + .AsIEmbeddingGenerator(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 866e43e172d..79e63162923 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -1034,7 +1034,7 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); AITool mcpTool = rawTool ? - ResponseTool.CreateMcpTool("deepwiki", new("https://mcp.deepwiki.com/mcp"), toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)).AsAITool() : + ResponseTool.CreateMcpTool("deepwiki", serverUri: new("https://mcp.deepwiki.com/mcp"), toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)).AsAITool() : new HostedMcpServerTool("deepwiki", "https://mcp.deepwiki.com/mcp") { ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, @@ -1506,6 +1506,19 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() Assert.Equal(1569, response.Usage.TotalTokenCount); } + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => client.GetResponseAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + private static IChatClient CreateResponseClient(HttpClient httpClient, string modelId) => new OpenAIClient( new ApiKeyCredential("apikey"), diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs new file mode 100644 index 00000000000..6b4b0fa0f1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs @@ -0,0 +1,15 @@ +// 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.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +internal sealed class ThrowUserAgentExceptionHandler : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + throw new InvalidOperationException($"User-Agent header: {request.Headers.UserAgent}"); +}