diff --git a/eng/packages/General.props b/eng/packages/General.props index a8de843a353..7565d36bac0 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -20,7 +20,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index 6e087263cca..43238de166c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -2,6 +2,8 @@ ## 10.1.1-preview.1.? (NOT YET RELEASED) +- Updated to depend on OpenAI 2.8.0. +- Updated public API signatures in `OpenAIClientExtensions` and `MicrosoftExtensionsAIResponsesExtensions` to match the corresponding breaking changes in OpenAI's Responses APIs. - Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. - Updated the OpenAI Responses and Chat Completion `IChatClient`s to populate `UsageDetails`'s `InputCachedTokenCount` and `ReasoningTokenCount`. - Updated handling of `HostedWebSearchTool`, `HostedFileSearchTool`, and `HostedImageGenerationTool` to pull OpenAI-specific diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 6d989c0b56d..9c2ed0348fb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -56,12 +56,12 @@ public static IEnumerable AsOpenAIResponseItems(this IEnumerable AsChatMessages(this IEnumerable items) => OpenAIResponsesChatClient.ToChatMessages(Throw.IfNull(items)); - /// Creates a Microsoft.Extensions.AI from an . - /// The to convert to a . + /// Creates a Microsoft.Extensions.AI from an . + /// The to convert to a . /// The options employed in the creation of the response. /// A converted . /// is . - public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) => + public static ChatResponse AsChatResponse(this ResponseResult response, CreateResponseOptions? options = null) => OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options, conversationId: null); /// @@ -74,35 +74,43 @@ public static ChatResponse AsChatResponse(this OpenAIResponse response, Response /// A sequence of converted instances. /// is . public static IAsyncEnumerable AsChatResponseUpdatesAsync( - this IAsyncEnumerable responseUpdates, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) => + this IAsyncEnumerable responseUpdates, CreateResponseOptions? options = null, CancellationToken cancellationToken = default) => OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, conversationId: null, cancellationToken: cancellationToken); - /// Creates an OpenAI from a . + /// Creates an OpenAI from a . /// The response to convert. /// The options employed in the creation of the response. - /// The created . - public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOptions? options = null) + /// The created . + public static ResponseResult AsOpenAIResponseResult(this ChatResponse response, ChatOptions? options = null) { _ = Throw.IfNull(response); - if (response.RawRepresentation is OpenAIResponse openAIResponse) + if (response.RawRepresentation is ResponseResult openAIResponse) { return openAIResponse; } - return OpenAIResponsesModelFactory.OpenAIResponse( - response.ResponseId, - response.CreatedAt ?? default, - ResponseStatus.Completed, - usage: null, // No way to construct a ResponseTokenUsage right now from external to the OpenAI library - maxOutputTokenCount: options?.MaxOutputTokens, - outputItems: OpenAIResponsesChatClient.ToOpenAIResponseItems(response.Messages, options), - parallelToolCallsEnabled: options?.AllowMultipleToolCalls ?? false, - model: response.ModelId ?? options?.ModelId, - temperature: options?.Temperature, - topP: options?.TopP, - previousResponseId: options?.ConversationId, - instructions: options?.Instructions); + ResponseResult result = new() + { + ConversationOptions = OpenAIClientExtensions.IsConversationId(response.ConversationId) ? new(response.ConversationId) : null, + CreatedAt = response.CreatedAt ?? default, + Id = response.ResponseId, + Instructions = options?.Instructions, + MaxOutputTokenCount = options?.MaxOutputTokens, + Model = response.ModelId ?? options?.ModelId, + ParallelToolCallsEnabled = options?.AllowMultipleToolCalls ?? true, + Status = ResponseStatus.Completed, + Temperature = options?.Temperature, + TopP = options?.TopP, + Usage = OpenAIResponsesChatClient.ToResponseTokenUsage(response.Usage), + }; + + foreach (var responseItem in OpenAIResponsesChatClient.ToOpenAIResponseItems(response.Messages, options)) + { + result.OutputItems.Add(responseItem); + } + + return result; } /// Adds the to the list of s. @@ -111,7 +119,7 @@ public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOp /// /// does not derive from , so it cannot be added directly to a list of s. /// Instead, this method wraps the provided in an and adds that to the list. - /// The returned by will + /// The returned by will /// be able to unwrap the when it processes the list of tools and use the provided as-is. /// public static void Add(this IList tools, ResponseTool tool) @@ -127,7 +135,7 @@ public static void Add(this IList tools, ResponseTool tool) /// /// /// The returned tool is only suitable for use with the returned by - /// (or s that delegate + /// (or s that delegate /// to such an instance). It is likely to be ignored by any other implementation. /// /// @@ -136,7 +144,7 @@ public static void Add(this IList tools, ResponseTool tool) /// , those types should be preferred instead of this method, as they are more portable, /// capable of being respected by any implementation. This method does not attempt to /// map the supplied to any of those types, it simply wraps it as-is: - /// the returned by will + /// the returned by will /// be able to unwrap the when it processes the list of tools. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index f696e394b44..36d8677e70a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -25,7 +25,7 @@ namespace Microsoft.Extensions.AI; public static class OpenAIClientExtensions { /// Key into AdditionalProperties used to store a strict option. - private const string StrictKey = "strictJsonSchema"; + private const string StrictKey = "strict"; /// Gets the default OpenAI endpoint. internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); @@ -111,11 +111,11 @@ static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode public static IChatClient AsIChatClient(this ChatClient chatClient) => new OpenAIChatClient(chatClient); - /// Gets an for use with this . + /// Gets an for use with this . /// The client. - /// An that can be used to converse via the . + /// An that can be used to converse via the . /// is . - public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => + public static IChatClient AsIChatClient(this ResponsesClient responseClient) => new OpenAIResponsesChatClient(responseClient); /// Gets an for use with this . @@ -246,6 +246,14 @@ internal static void PatchModelIfNotSet(ref JsonPatch patch, string? modelId) internal static T? GetProperty(this AITool tool, string name) => tool.AdditionalProperties?.TryGetValue(name, out object? value) is true && value is T tValue ? tValue : default; + /// Gets whether an ID is an OpenAI conversation ID. + /// + /// Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and + /// we can use that to disambiguate whether we're looking at a conversation ID or something else, like a response ID. + /// + internal static bool IsConversationId(string? id) => + id?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true; + /// Used to create the JSON payload for an OpenAI tool description. internal sealed class ToolJson { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 98c30b73526..e6359cbdd7a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -5,6 +5,7 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -23,53 +24,38 @@ namespace Microsoft.Extensions.AI; -/// Represents an for an . +/// Represents an for an . internal sealed class OpenAIResponsesChatClient : IChatClient { // These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. - private static readonly Func, ResponseCreationOptions, RequestOptions, Task>>? - _createResponseAsync = - (Func, ResponseCreationOptions, RequestOptions, Task>>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.CreateResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) - ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, Task>>)); - - private static readonly Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>? + + private static readonly Func>? _createResponseStreamingAsync = - (Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.CreateResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) - ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>)); - - private static readonly Func>>? - _getResponseAsync = - (Func>>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.GetResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(string), typeof(RequestOptions)], null) - ?.CreateDelegate(typeof(Func>>)); - - private static readonly Func>? + (Func>?) + typeof(ResponsesClient).GetMethod( + nameof(ResponsesClient.CreateResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(CreateResponseOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func>)); + + private static readonly Func>? _getResponseStreamingAsync = - (Func>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.GetResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(string), typeof(RequestOptions), typeof(int?)], null) - ?.CreateDelegate(typeof(Func>)); + (Func>?) + typeof(ResponsesClient).GetMethod( + nameof(ResponsesClient.GetResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(GetResponseOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func>)); /// Metadata about the client. private readonly ChatClientMetadata _metadata; - /// The underlying . - private readonly OpenAIResponseClient _responseClient; + /// The underlying . + private readonly ResponsesClient _responseClient; - /// Initializes a new instance of the class for the specified . + /// Initializes a new instance of the class for the specified . /// The underlying client. /// is . - public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) + public OpenAIResponsesChatClient(ResponsesClient responseClient) { _ = Throw.IfNull(responseClient); @@ -86,7 +72,7 @@ public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) return serviceKey is not null ? null : serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(OpenAIResponseClient) ? _responseClient : + serviceType == typeof(ResponsesClient) ? _responseClient : serviceType.IsInstanceOfType(this) ? this : null; } @@ -97,76 +83,79 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - // Convert the inputs into what OpenAIResponseClient expects. - var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + // Convert the inputs into what ResponsesClient expects. + var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); // Provided continuation token signals that an existing background response should be fetched. if (GetContinuationToken(messages, options) is { } token) { - var getTask = _getResponseAsync is not null ? - _getResponseAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: false)) : - _responseClient.GetResponseAsync(token.ResponseId, cancellationToken); - var response = (await getTask.ConfigureAwait(false)).Value; - + var getTask = _responseClient.GetResponseAsync(token.ResponseId, include: null, stream: null, startingAfter: null, includeObfuscation: null, cancellationToken.ToRequestOptions(streaming: false)); + var response = (ResponseResult)await getTask.ConfigureAwait(false); return FromOpenAIResponse(response, openAIOptions, openAIConversationId); } - var openAIResponseItems = ToOpenAIResponseItems(messages, options); + foreach (var responseItem in ToOpenAIResponseItems(messages, options)) + { + openAIOptions.InputItems.Add(responseItem); + } - // Make the call to the OpenAIResponseClient. - var createTask = _createResponseAsync is not null ? - _createResponseAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : - _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken); - var openAIResponse = (await createTask.ConfigureAwait(false)).Value; + // Make the call to the ResponsesClient. + var createTask = _responseClient.CreateResponseAsync((BinaryContent)openAIOptions, cancellationToken.ToRequestOptions(streaming: false)); + var openAIResponsesResult = (ResponseResult)await createTask.ConfigureAwait(false); // Convert the response to a ChatResponse. - return FromOpenAIResponse(openAIResponse, openAIOptions, openAIConversationId); + return FromOpenAIResponse(openAIResponsesResult, openAIOptions, openAIConversationId); } - internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions, string? conversationId) + internal static ChatResponse FromOpenAIResponse(ResponseResult responseResult, CreateResponseOptions? openAIOptions, string? conversationId) { // Convert and return the results. ChatResponse response = new() { - ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? openAIResponse.Id), - CreatedAt = openAIResponse.CreatedAt, - ContinuationToken = CreateContinuationToken(openAIResponse), - FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), - ModelId = openAIResponse.Model, - RawRepresentation = openAIResponse, - ResponseId = openAIResponse.Id, - Usage = ToUsageDetails(openAIResponse), + ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? responseResult.Id), + CreatedAt = responseResult.CreatedAt, + ContinuationToken = CreateContinuationToken(responseResult), + FinishReason = AsFinishReason(responseResult.IncompleteStatusDetails?.Reason), + ModelId = responseResult.Model, + RawRepresentation = responseResult, + ResponseId = responseResult.Id, + Usage = ToUsageDetails(responseResult), }; - if (!string.IsNullOrEmpty(openAIResponse.EndUserId)) + if (!string.IsNullOrEmpty(responseResult.EndUserId)) { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId; + (response.AdditionalProperties ??= [])[nameof(responseResult.EndUserId)] = responseResult.EndUserId; } - if (openAIResponse.Error is not null) + if (responseResult.Error is not null) { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error; + (response.AdditionalProperties ??= [])[nameof(responseResult.Error)] = responseResult.Error; } - if (openAIResponse.OutputItems is not null) + if (responseResult.OutputItems is not null) { - response.Messages = [.. ToChatMessages(openAIResponse.OutputItems, openAIOptions)]; + response.Messages = [.. ToChatMessages(responseResult.OutputItems, openAIOptions)]; - if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error) + if (response.Messages.LastOrDefault() is { } lastMessage && responseResult.Error is { } error) { lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); } foreach (var message in response.Messages) { - message.CreatedAt ??= openAIResponse.CreatedAt; + message.CreatedAt ??= responseResult.CreatedAt; } } + if (responseResult.SafetyIdentifier is not null) + { + (response.AdditionalProperties ??= [])[nameof(responseResult.SafetyIdentifier)] = responseResult.SafetyIdentifier; + } + return response; } - internal static IEnumerable ToChatMessages(IEnumerable items, ResponseCreationOptions? options = null) + internal static IEnumerable ToChatMessages(IEnumerable items, CreateResponseOptions? options = null) { ChatMessage? message = null; @@ -185,7 +174,7 @@ internal static IEnumerable ToChatMessages(IEnumerable)message.Contents).AddRange(ToAIContents(messageItem.Content)); break; @@ -252,30 +241,38 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); + openAIOptions.StreamingEnabled = true; // Provided continuation token signals that an existing background response should be fetched. if (GetContinuationToken(messages, options) is { } token) { + GetResponseOptions getOptions = new(token.ResponseId) { StartingAfter = token.SequenceNumber, StreamingEnabled = true }; + + Debug.Assert(_getResponseStreamingAsync is not null, $"Unable to find {nameof(_getResponseStreamingAsync)} method"); IAsyncEnumerable getUpdates = _getResponseStreamingAsync is not null ? - _getResponseStreamingAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: true), token.SequenceNumber) : - _responseClient.GetResponseStreamingAsync(token.ResponseId, token.SequenceNumber, cancellationToken); + _getResponseStreamingAsync(_responseClient, getOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _responseClient.GetResponseStreamingAsync(getOptions, cancellationToken); return FromOpenAIStreamingResponseUpdatesAsync(getUpdates, openAIOptions, openAIConversationId, token.ResponseId, cancellationToken); } - var openAIResponseItems = ToOpenAIResponseItems(messages, options); + foreach (var responseItem in ToOpenAIResponseItems(messages, options)) + { + openAIOptions.InputItems.Add(responseItem); + } - var createUpdates = _createResponseStreamingAsync is not null ? - _createResponseStreamingAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : - _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); + Debug.Assert(_createResponseStreamingAsync is not null, $"Unable to find {nameof(_createResponseStreamingAsync)} method"); + AsyncCollectionResult createUpdates = _createResponseStreamingAsync is not null ? + _createResponseStreamingAsync(_responseClient, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _responseClient.CreateResponseStreamingAsync(openAIOptions, cancellationToken); return FromOpenAIStreamingResponseUpdatesAsync(createUpdates, openAIOptions, openAIConversationId, cancellationToken: cancellationToken); } internal static async IAsyncEnumerable FromOpenAIStreamingResponseUpdatesAsync( IAsyncEnumerable streamingResponseUpdates, - ResponseCreationOptions? options, + CreateResponseOptions? options, string? conversationId, string? resumeResponseId = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -360,7 +357,7 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) => latestResponseStatus = completedUpdate.Response?.Status; var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null); update.FinishReason = - ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? + AsFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? (anyFunctions ? ChatFinishReason.ToolCalls : ChatFinishReason.Stop); yield return update; @@ -372,7 +369,7 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) => { case MessageResponseItem mri: lastMessageId = outputItemAddedUpdate.Item.Id; - lastRole = ToChatRole(mri.Role); + lastRole = AsChatRole(mri.Role); break; case FunctionCallResponseItem fcri: @@ -530,7 +527,7 @@ void UpdateConversationId(string? id) /// void IDisposable.Dispose() { - // Nothing to dispose. Implementation required for the IChatClient interface. + // Nothing to dispose. } internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null) @@ -544,39 +541,60 @@ void IDisposable.Dispose() return ToResponseTool(aiFunction, options); case HostedWebSearchTool webSearchTool: - return ResponseTool.CreateWebSearchTool( - webSearchTool.GetProperty(nameof(WebSearchTool.UserLocation)), - webSearchTool.GetProperty(nameof(WebSearchTool.SearchContextSize)), - webSearchTool.GetProperty(nameof(WebSearchTool.Filters))); + return new WebSearchTool + { + Filters = webSearchTool.GetProperty(nameof(WebSearchTool.Filters)), + SearchContextSize = webSearchTool.GetProperty(nameof(WebSearchTool.SearchContextSize)), + UserLocation = webSearchTool.GetProperty(nameof(WebSearchTool.UserLocation)), + }; case HostedFileSearchTool fileSearchTool: - return ResponseTool.CreateFileSearchTool( - fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], - fileSearchTool.MaximumResultCount, - fileSearchTool.GetProperty(nameof(FileSearchTool.RankingOptions)), - fileSearchTool.GetProperty(nameof(FileSearchTool.Filters))); - - case HostedImageGenerationTool imageGenerationTool: - return ToImageResponseTool(imageGenerationTool); + return new FileSearchTool(fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? []) + { + Filters = fileSearchTool.GetProperty(nameof(FileSearchTool.Filters)), + MaxResultCount = fileSearchTool.MaximumResultCount, + RankingOptions = fileSearchTool.GetProperty(nameof(FileSearchTool.RankingOptions)), + }; case HostedCodeInterpreterTool codeTool: - return ResponseTool.CreateCodeInterpreterTool( - new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? + return new CodeInterpreterTool( + new(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(ids) : new())); + case HostedImageGenerationTool imageGenerationTool: + ImageGenerationOptions? igo = imageGenerationTool.Options; + return new ImageGenerationTool + { + Background = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Background)), + InputFidelity = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputFidelity)), + InputImageMask = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputImageMask)), + Model = igo?.ModelId, + ModerationLevel = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.ModerationLevel)), + OutputCompressionFactor = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.OutputCompressionFactor)), + OutputFileFormat = igo?.MediaType is { } mediaType ? + mediaType switch + { + "image/png" => ImageGenerationToolOutputFileFormat.Png, + "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, + "image/webp" => ImageGenerationToolOutputFileFormat.Webp, + _ => null, + } : + null, + PartialImageCount = igo?.StreamingCount, + Quality = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Quality)), + Size = igo?.ImageSize is { } size ? + new ImageGenerationToolSize(size.Width, size.Height) : + null, + }; + case HostedMcpServerTool mcpTool: - McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? - ResponseTool.CreateMcpTool( - mcpTool.ServerName, - url, - mcpTool.AuthorizationToken, - mcpTool.ServerDescription) : - ResponseTool.CreateMcpTool( - mcpTool.ServerName, - new McpToolConnectorId(mcpTool.ServerAddress), - mcpTool.AuthorizationToken, - mcpTool.ServerDescription); + McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? serverAddressUrl) ? + new McpTool(mcpTool.ServerName, serverAddressUrl) : + new McpTool(mcpTool.ServerName, new McpToolConnectorId(mcpTool.ServerAddress)); + + responsesMcpTool.ServerDescription = mcpTool.ServerDescription; + responsesMcpTool.AuthorizationToken = mcpTool.AuthorizationToken; if (mcpTool.AllowedTools is not null) { @@ -621,48 +639,21 @@ void IDisposable.Dispose() internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { - bool? strict = + bool? strictModeEnabled = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); - return ResponseTool.CreateFunctionTool( + return new FunctionTool( aiFunction.Name, - OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), - strict, - aiFunction.Description); - } - - internal static ImageGenerationTool ToImageResponseTool(HostedImageGenerationTool imageGenerationTool) - { - ImageGenerationOptions? options = imageGenerationTool.Options; - - return new() + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strictModeEnabled), + strictModeEnabled) { - Background = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Background)), - InputFidelity = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputFidelity)), - InputImageMask = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputImageMask)), - Model = options?.ModelId, - ModerationLevel = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.ModerationLevel)), - OutputCompressionFactor = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.OutputCompressionFactor)), - OutputFileFormat = options?.MediaType is not null ? - options.MediaType switch - { - "image/png" => ImageGenerationToolOutputFileFormat.Png, - "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, - "image/webp" => ImageGenerationToolOutputFileFormat.Webp, - _ => null, - } : - null, - PartialImageCount = options?.StreamingCount, - Quality = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Quality)), - Size = options?.ImageSize is not null ? - new ImageGenerationToolSize(options.ImageSize.Value.Width, options.ImageSize.Value.Height) : - null + FunctionDescription = aiFunction.Description, }; } /// Creates a from a . - private static ChatRole ToChatRole(MessageRole? role) => + private static ChatRole AsChatRole(MessageRole? role) => role switch { MessageRole.System => ChatRole.System, @@ -672,23 +663,26 @@ private static ChatRole ToChatRole(MessageRole? role) => }; /// Creates a from a . - private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) => + private static ChatFinishReason? AsFinishReason(ResponseIncompleteStatusReason? statusReason) => statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter : statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length : null; - /// Converts a to a . - private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options, out string? openAIConversationId) + /// Converts a to a . + private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out string? openAIConversationId) { openAIConversationId = null; if (options is null) { - return new(); + return new() + { + Model = _responseClient.Model, + }; } bool hasRawRco = false; - if (options.RawRepresentationFactory?.Invoke(this) is ResponseCreationOptions result) + if (options.RawRepresentationFactory?.Invoke(this) is CreateResponseOptions result) { hasRawRco = true; } @@ -697,32 +691,29 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt result = new(); } + result.BackgroundModeEnabled ??= options.AllowBackgroundResponses; result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.Model ??= options.ModelId ?? _responseClient.Model; result.Temperature ??= options.Temperature; result.TopP ??= options.TopP; - result.BackgroundModeEnabled ??= options.AllowBackgroundResponses; - OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options.ModelId); - // If the ResponseCreationOptions.PreviousResponseId is already set (likely rare), then we don't need to do + // If the CreateResponseOptions.PreviousResponseId is already set (likely rare), then we don't need to do // anything with regards to Conversation, because they're mutually exclusive and we would want to ignore - // ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the ResponseCreationOptions + // ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the CreateResponseOptions // instance to see if a conversation ID has already been set on it and use that conversation ID subsequently if // it has. If one hasn't been set, but ChatOptions.ConversationId has been set, we'll either set - // ResponseCreationOptions.Conversation if the string represents a conversation ID or else PreviousResponseId. + // CreateResponseOptions.Conversation if the string represents a conversation ID or else PreviousResponseId. if (result.PreviousResponseId is null) { - // Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and - // we can use that to disambiguate whether we're looking at a conversation ID or a response ID. - string? chatOptionsConversationId = options.ConversationId; - bool chatOptionsHasOpenAIConversationId = chatOptionsConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true; + bool chatOptionsHasOpenAIConversationId = OpenAIClientExtensions.IsConversationId(options.ConversationId); if (hasRawRco || chatOptionsHasOpenAIConversationId) { - _ = result.Patch.TryGetValue("$.conversation"u8, out openAIConversationId); + openAIConversationId = result.ConversationOptions?.ConversationId; if (openAIConversationId is null && chatOptionsHasOpenAIConversationId) { - result.Patch.Set("$.conversation"u8, chatOptionsConversationId!); - openAIConversationId = chatOptionsConversationId; + result.ConversationOptions = new(options.ConversationId); + openAIConversationId = options.ConversationId; } } @@ -765,7 +756,6 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt break; case AutoChatToolMode: - case null: result.ToolChoice = ResponseToolChoice.CreateAutoChoice(); break; @@ -1063,9 +1053,10 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera break; case TextReasoningContent reasoningContent: - yield return OpenAIResponsesModelFactory.ReasoningResponseItem( - encryptedContent: reasoningContent.ProtectedData, - summaryText: reasoningContent.Text); + yield return new ReasoningResponseItem(reasoningContent.Text) + { + EncryptedContent = reasoningContent.ProtectedData, + }; break; case FunctionCallContent callContent: @@ -1119,11 +1110,11 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera } } - /// Extract usage details from an . - private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse) + /// Extract usage details from a into a . + private static UsageDetails? ToUsageDetails(ResponseResult? responseResult) { UsageDetails? ud = null; - if (openAIResponse?.Usage is { } usage) + if (responseResult?.Usage is { } usage) { ud = new() { @@ -1138,6 +1129,38 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera return ud; } + /// Converts a to a . + internal static ResponseTokenUsage? ToResponseTokenUsage(UsageDetails? usageDetails) + { + ResponseTokenUsage? rtu = null; + if (usageDetails is not null) + { + rtu = new() + { + InputTokenCount = (int?)usageDetails.InputTokenCount ?? 0, + OutputTokenCount = (int?)usageDetails.OutputTokenCount ?? 0, + TotalTokenCount = (int?)usageDetails.TotalTokenCount ?? 0, + InputTokenDetails = new(), + OutputTokenDetails = new(), + }; + + if (usageDetails.AdditionalCounts is { } additionalCounts) + { + if (additionalCounts.TryGetValue($"{nameof(ResponseTokenUsage.InputTokenDetails)}.{nameof(ResponseInputTokenUsageDetails.CachedTokenCount)}", out int? cachedTokenCount)) + { + rtu.InputTokenDetails.CachedTokenCount = cachedTokenCount.GetValueOrDefault(); + } + + if (additionalCounts.TryGetValue($"{nameof(ResponseTokenUsage.OutputTokenDetails)}.{nameof(ResponseOutputTokenUsageDetails.ReasoningTokenCount)}", out int? reasoningTokenCount)) + { + rtu.OutputTokenDetails.ReasoningTokenCount = reasoningTokenCount.GetValueOrDefault(); + } + } + } + + return rtu; + } + /// Convert a sequence of s to a list of . private static List ToAIContents(IEnumerable contents) { @@ -1145,46 +1168,40 @@ private static List ToAIContents(IEnumerable con foreach (ResponseContentPart part in contents) { + AIContent? content; switch (part.Kind) { case ResponseContentPartKind.InputText or ResponseContentPartKind.OutputText: - TextContent text = new(part.Text) { RawRepresentation = part }; + TextContent text = new(part.Text); PopulateAnnotations(part, text); - results.Add(text); + content = text; break; - case ResponseContentPartKind.InputFile: - if (!string.IsNullOrWhiteSpace(part.InputImageFileId)) - { - results.Add(new HostedFileContent(part.InputImageFileId) { MediaType = "image/*", RawRepresentation = part }); - } - else if (!string.IsNullOrWhiteSpace(part.InputFileId)) - { - results.Add(new HostedFileContent(part.InputFileId) { Name = part.InputFilename, 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, - }); - } - + case ResponseContentPartKind.InputFile or ResponseContentPartKind.InputImage: + content = + !string.IsNullOrWhiteSpace(part.InputImageFileId) ? new HostedFileContent(part.InputImageFileId) { MediaType = "image/*" } : + !string.IsNullOrWhiteSpace(part.InputFileId) ? new HostedFileContent(part.InputFileId) { Name = part.InputFilename } : + part.InputFileBytes is not null ? new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream") { Name = part.InputFilename } : + null; break; case ResponseContentPartKind.Refusal: - results.Add(new ErrorContent(part.Refusal) + content = new ErrorContent(part.Refusal) { ErrorCode = nameof(ResponseContentPartKind.Refusal), - RawRepresentation = part, - }); + }; break; default: - results.Add(new() { RawRepresentation = part }); + content = new(); break; } + + if (content is not null) + { + content.RawRepresentation = part; + results.Add(content); + } } return results; @@ -1288,7 +1305,7 @@ private static void AddCodeInterpreterContents(CodeInterpreterCallResponseItem c }); } - private static void AddImageGenerationContents(ImageGenerationCallResponseItem outputItem, ResponseCreationOptions? options, IList contents) + private static void AddImageGenerationContents(ImageGenerationCallResponseItem outputItem, CreateResponseOptions? options, IList contents) { var imageGenTool = options?.Tools.OfType().FirstOrDefault(); string outputFormat = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; @@ -1302,14 +1319,11 @@ private static void AddImageGenerationContents(ImageGenerationCallResponseItem o { ImageId = outputItem.Id, RawRepresentation = outputItem, - Outputs = new List - { - new DataContent(outputItem.ImageResultBytes, $"image/{outputFormat}") - } + Outputs = [new DataContent(outputItem.ImageResultBytes, $"image/{outputFormat}")] }); } - private static ImageGenerationToolResultContent GetImageGenerationResult(StreamingResponseImageGenerationCallPartialImageUpdate update, ResponseCreationOptions? options) + private static ImageGenerationToolResultContent GetImageGenerationResult(StreamingResponseImageGenerationCallPartialImageUpdate update, CreateResponseOptions? options) { var imageGenTool = options?.Tools.OfType().FirstOrDefault(); var outputType = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; @@ -1333,15 +1347,13 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami }; } - private static OpenAIResponsesContinuationToken? CreateContinuationToken(OpenAIResponse openAIResponse) - { - return CreateContinuationToken( - responseId: openAIResponse.Id, - responseStatus: openAIResponse.Status, - isBackgroundModeEnabled: openAIResponse.BackgroundModeEnabled); - } + private static ResponsesClientContinuationToken? CreateContinuationToken(ResponseResult responseResult) => + CreateContinuationToken( + responseId: responseResult.Id, + responseStatus: responseResult.Status, + isBackgroundModeEnabled: responseResult.BackgroundModeEnabled); - private static OpenAIResponsesContinuationToken? CreateContinuationToken( + private static ResponsesClientContinuationToken? CreateContinuationToken( string responseId, ResponseStatus? responseStatus, bool? isBackgroundModeEnabled, @@ -1359,7 +1371,7 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami if ((responseStatus is ResponseStatus.InProgress or ResponseStatus.Queued) || (responseStatus is null && updateSequenceNumber is not null)) { - return new OpenAIResponsesContinuationToken(responseId) + return new ResponsesClientContinuationToken(responseId) { SequenceNumber = updateSequenceNumber, }; @@ -1371,7 +1383,7 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami return null; } - private static OpenAIResponsesContinuationToken? GetContinuationToken(IEnumerable messages, ChatOptions? options = null) + private static ResponsesClientContinuationToken? GetContinuationToken(IEnumerable messages, ChatOptions? options = null) { if (options?.ContinuationToken is { } token) { @@ -1380,7 +1392,7 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token."); } - return OpenAIResponsesContinuationToken.FromToken(token); + return ResponsesClientContinuationToken.FromToken(token); } return null; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/ResponsesClientContinuationToken.cs similarity index 81% rename from src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs rename to src/Libraries/Microsoft.Extensions.AI.OpenAI/ResponsesClientContinuationToken.cs index 8e6f5ffd71c..770f5b10afa 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/ResponsesClientContinuationToken.cs @@ -13,10 +13,10 @@ namespace Microsoft.Extensions.AI; /// The token is used for resuming streamed background responses and continuing /// non-streamed background responses until completion. /// -internal sealed class OpenAIResponsesContinuationToken : ResponseContinuationToken +internal sealed class ResponsesClientContinuationToken : ResponseContinuationToken { - /// Initializes a new instance of the class. - internal OpenAIResponsesContinuationToken(string responseId) + /// Initializes a new instance of the class. + internal ResponsesClientContinuationToken(string responseId) { ResponseId = responseId; } @@ -49,13 +49,13 @@ public override ReadOnlyMemory ToBytes() return stream.ToArray(); } - /// Create a new instance of from the provided . + /// Create a new instance of from the provided . /// - /// The token to create the from. - /// A equivalent of the provided . - internal static OpenAIResponsesContinuationToken FromToken(ResponseContinuationToken token) + /// The token to create the from. + /// A equivalent of the provided . + internal static ResponsesClientContinuationToken FromToken(ResponseContinuationToken token) { - if (token is OpenAIResponsesContinuationToken openAIResponsesContinuationToken) + if (token is ResponsesClientContinuationToken openAIResponsesContinuationToken) { return openAIResponsesContinuationToken; } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs index e9984318254..a51a674cf71 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs @@ -49,8 +49,8 @@ var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. -var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning disable OPENAI001 // GetResponsesClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetResponsesClient("gpt-4o-mini").AsIChatClient(); #pragma warning restore OPENAI001 var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); @@ -66,7 +66,7 @@ #endif var azureOpenAIEndpoint = new Uri(new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Endpoint. See the README for details.")), "/openai/v1"); #if (IsManagedIdentity) -#pragma warning disable OPENAI001 // OpenAIClient(AuthenticationPolicy, OpenAIClientOptions) and GetOpenAIResponseClient(string) are experimental and subject to change or removal in future updates. +#pragma warning disable OPENAI001 // OpenAIClient(AuthenticationPolicy, OpenAIClientOptions) and GetResponsesClient(string) are experimental and subject to change or removal in future updates. var azureOpenAi = new OpenAIClient( new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }); @@ -75,9 +75,9 @@ var openAIOptions = new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }; var azureOpenAi = new OpenAIClient(new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key. See the README for details.")), openAIOptions); -#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. +#pragma warning disable OPENAI001 // GetResponsesClient(string) is experimental and subject to change or removal in future updates. #endif -var chatClient = azureOpenAi.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +var chatClient = azureOpenAi.GetResponsesClient("gpt-4o-mini").AsIChatClient(); #pragma warning restore OPENAI001 var embeddingGenerator = azureOpenAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 349de2e771a..1293d14aec3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -432,7 +432,7 @@ private async Task AvailableTools_SchemasAreAccepted(bool strict) if (strict) { - aiFuncOptions.AdditionalProperties = new Dictionary { ["strictJsonSchema"] = true }; + aiFuncOptions.AdditionalProperties = new Dictionary { ["strict"] = true }; } return aiFuncOptions; @@ -444,7 +444,7 @@ private async Task AvailableTools_SchemasAreAccepted(bool strict) if (strict) { - additionalProperties["strictJsonSchema"] = true; + additionalProperties["strict"] = true; } return new CustomAIFunction($"CustomMethod{methodCount++}", schema, additionalProperties); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 458256523e4..99aa44cdd0f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -332,7 +332,7 @@ public async Task ChatOptions_StrictRespected() Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], AdditionalProperties = new() { - ["strictJsonSchema"] = true, + ["strict"] = true, }, }); Assert.NotNull(response); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 0ab4ea2c6b1..1a711b7417c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -47,7 +47,7 @@ public void AsOpenAIChatResponseFormat_HandlesVariousFormats() """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIChatResponseFormat( - new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strict"] = true } }); Assert.NotNull(jsonSchema); Assert.Equal(RemoveWhitespace(""" { @@ -82,7 +82,7 @@ public void AsOpenAIResponseTextFormat_HandlesVariousFormats() """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIResponseTextFormat( - new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strict"] = true } }); Assert.NotNull(jsonSchema); Assert.Equal(ResponseTextFormatKind.JsonSchema, jsonSchema.Kind); Assert.Equal(RemoveWhitespace(""" @@ -788,7 +788,7 @@ static async IAsyncEnumerable CreateUpdates() [Fact] public void AsChatResponse_ConvertsOpenAIResponse() { - Assert.Throws("response", () => ((OpenAIResponse)null!).AsChatResponse()); + Assert.Throws("response", () => ((ResponseResult)null!).AsChatResponse()); // The OpenAI library currently doesn't provide any way to create an OpenAIResponse instance, // as all constructors/factory methods currently are internal. Update this test when such functionality is available. @@ -1355,32 +1355,32 @@ public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleUpdate [Fact] public void AsOpenAIResponse_WithNullArgument_ThrowsArgumentNullException() { - Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIResponse()); + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIResponseResult()); } [Fact] public void AsOpenAIResponse_WithRawRepresentation_ReturnsOriginal() { - var originalOpenAIResponse = OpenAIResponsesModelFactory.OpenAIResponse( - "original-response-id", - new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), - ResponseStatus.Completed, - usage: null, - maxOutputTokenCount: 100, - outputItems: [], - parallelToolCallsEnabled: false, - model: "gpt-4", - temperature: 0.7f, - topP: 0.9f, - previousResponseId: "prev-id", - instructions: "Test instructions"); + ResponseResult originalOpenAIResponse = new() + { + Id = "original-response-id", + CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + Status = ResponseStatus.Completed, + MaxOutputTokenCount = 100, + ParallelToolCallsEnabled = false, + Model = "gpt-4", + Temperature = 0.7f, + TopP = 0.9f, + PreviousResponseId = "prev-id", + Instructions = "Test instructions" + }; var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) { RawRepresentation = originalOpenAIResponse }; - var result = chatResponse.AsOpenAIResponse(); + var result = chatResponse.AsOpenAIResponseResult(); Assert.Same(originalOpenAIResponse, result); } @@ -1396,7 +1396,7 @@ public void AsOpenAIResponse_WithBasicChatResponse_CreatesValidOpenAIResponse() FinishReason = ChatFinishReason.Stop }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.NotNull(openAIResponse); Assert.Equal("test-response-id", openAIResponse.Id); @@ -1415,6 +1415,7 @@ public void AsOpenAIResponse_WithChatOptions_IncludesOptionsInResponse() { var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test message")) { + ConversationId = "conv_123", ResponseId = "options-test", ModelId = "gpt-3.5-turbo" }; @@ -1423,20 +1424,19 @@ public void AsOpenAIResponse_WithChatOptions_IncludesOptionsInResponse() { MaxOutputTokens = 500, AllowMultipleToolCalls = true, - ConversationId = "conversation-123", Instructions = "You are a helpful assistant.", Temperature = 0.8f, TopP = 0.95f, ModelId = "override-model" }; - var openAIResponse = chatResponse.AsOpenAIResponse(options); + var openAIResponse = chatResponse.AsOpenAIResponseResult(options); Assert.Equal("options-test", openAIResponse.Id); Assert.Equal("gpt-3.5-turbo", openAIResponse.Model); Assert.Equal(500, openAIResponse.MaxOutputTokenCount); Assert.True(openAIResponse.ParallelToolCallsEnabled); - Assert.Equal("conversation-123", openAIResponse.PreviousResponseId); + Assert.Equal("conv_123", openAIResponse.ConversationOptions?.ConversationId); Assert.Equal("You are a helpful assistant.", openAIResponse.Instructions); Assert.Equal(0.8f, openAIResponse.Temperature); Assert.Equal(0.95f, openAIResponse.TopP); @@ -1451,7 +1451,7 @@ public void AsOpenAIResponse_WithEmptyMessages_CreatesResponseWithEmptyOutputIte ModelId = "gpt-4" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.Equal("empty-response", openAIResponse.Id); Assert.Equal("gpt-4", openAIResponse.Model); @@ -1477,7 +1477,7 @@ public void AsOpenAIResponse_WithMultipleMessages_ConvertsAllMessages() ResponseId = "multi-message-response" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.Equal(4, openAIResponse.OutputItems.Count); @@ -1514,7 +1514,7 @@ public void AsOpenAIResponse_WithToolMessages_ConvertsCorrectly() ResponseId = "tool-message-test" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); var outputItems = openAIResponse.OutputItems.ToArray(); Assert.Equal(4, outputItems.Length); @@ -1545,7 +1545,7 @@ public void AsOpenAIResponse_WithSystemAndUserMessages_ConvertsCorrectly() ResponseId = "system-user-test" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); var outputItems = openAIResponse.OutputItems.ToArray(); Assert.Equal(3, outputItems.Length); @@ -1564,15 +1564,15 @@ public void AsOpenAIResponse_WithDefaultValues_UsesExpectedDefaults() { var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Default test")); - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.NotNull(openAIResponse); Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); - Assert.False(openAIResponse.ParallelToolCallsEnabled); + Assert.True(openAIResponse.ParallelToolCallsEnabled); Assert.Null(openAIResponse.MaxOutputTokenCount); Assert.Null(openAIResponse.Temperature); Assert.Null(openAIResponse.TopP); - Assert.Null(openAIResponse.PreviousResponseId); + Assert.Null(openAIResponse.ConversationOptions); Assert.Null(openAIResponse.Instructions); Assert.NotNull(openAIResponse.OutputItems); } @@ -1587,7 +1587,7 @@ public void AsOpenAIResponse_WithOptionsButNoModelId_UsesOptionsModelId() ModelId = "options-model-id" }; - var openAIResponse = chatResponse.AsOpenAIResponse(options); + var openAIResponse = chatResponse.AsOpenAIResponseResult(options); Assert.Equal("options-model-id", openAIResponse.Model); } @@ -1605,7 +1605,7 @@ public void AsOpenAIResponse_WithBothModelIds_PrefersChatResponseModelId() ModelId = "options-model-id" }; - var openAIResponse = chatResponse.AsOpenAIResponse(options); + var openAIResponse = chatResponse.AsOpenAIResponseResult(options); Assert.Equal("response-model-id", openAIResponse.Model); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 830563a60e1..1421e780dca 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -14,7 +14,7 @@ public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOpenAIClient() - ?.GetOpenAIResponseClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini") + ?.GetResponsesClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini") .AsIChatClient(); public override bool FunctionInvokingChatClientSetsConversationId => true; diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 34526b683bd..1e19466ee7f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -11,7 +11,6 @@ using System.Net.Http; using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; @@ -28,7 +27,7 @@ public class OpenAIResponseClientTests [Fact] public void AsIChatClient_InvalidArgs_Throws() { - Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); + Assert.Throws("responseClient", () => ((ResponsesClient)null!).AsIChatClient()); } [Fact] @@ -39,7 +38,7 @@ public void AsIChatClient_ProducesExpectedMetadata() var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); - IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); + IChatClient chatClient = client.GetResponsesClient(model).AsIChatClient(); var metadata = chatClient.GetService(); Assert.Equal("openai", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); @@ -49,11 +48,11 @@ public void AsIChatClient_ProducesExpectedMetadata() [Fact] public void GetService_SuccessfullyReturnsUnderlyingClient() { - OpenAIResponseClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetOpenAIResponseClient("model"); + ResponsesClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetResponsesClient("model"); IChatClient chatClient = openAIClient.AsIChatClient(); Assert.Same(chatClient, chatClient.GetService()); - Assert.Same(openAIClient, chatClient.GetService()); + Assert.Same(openAIClient, chatClient.GetService()); using IChatClient pipeline = chatClient .AsBuilder() @@ -67,7 +66,7 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(pipeline.GetService()); Assert.NotNull(pipeline.GetService()); - Assert.Same(openAIClient, pipeline.GetService()); + Assert.Same(openAIClient, pipeline.GetService()); Assert.IsType(pipeline.GetService()); } @@ -295,7 +294,7 @@ public async Task BasicReasoningResponse_Streaming() List updates = []; await foreach (var update in client.GetStreamingResponseAsync("Calculate the sum of the first 5 positive integers.", new() { - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { @@ -428,7 +427,7 @@ public async Task ReasoningTextDelta_Streaming() List updates = []; await foreach (var update in client.GetStreamingResponseAsync("Solve this problem step by step.", new() { - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { @@ -609,7 +608,6 @@ public async Task MissingAbstractionResponse_NonStreaming() "display_height": 768 } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -700,7 +698,7 @@ public async Task MissingAbstractionResponse_NonStreaming() ChatOptions chatOptions = new() { Tools = [ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1024, 768).AsAITool()], - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Concise }, } @@ -745,7 +743,6 @@ public async Task MissingAbstractionResponse_Streaming() "display_height": 768 } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -789,7 +786,7 @@ public async Task MissingAbstractionResponse_Streaming() ChatOptions chatOptions = new() { Tools = [ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1024, 768).AsAITool()], - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Concise }, } @@ -843,7 +840,6 @@ public async Task ChatOptions_StrictRespected() ] } ], - "tool_choice": "auto", "tools": [ { "type": "function", @@ -894,7 +890,7 @@ public async Task ChatOptions_StrictRespected() Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], AdditionalProperties = new() { - ["strictJsonSchema"] = true, + ["strict"] = true, }, }); Assert.NotNull(response); @@ -1039,7 +1035,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio { RawRepresentationFactory = (c) => { - ResponseCreationOptions openAIOptions = new() + CreateResponseOptions openAIOptions = new() { MaxOutputTokenCount = 10, PreviousResponseId = "resp_42", @@ -1202,7 +1198,6 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) "server_url": "https://mcp.deepwiki.com/mcp" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -1257,7 +1252,6 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) }, "verbosity": "medium" }, - "tool_choice": "auto", "tools": [ { "type": "mcp", @@ -1317,7 +1311,6 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) "server_url": "https://mcp.deepwiki.com/mcp" } ], - "tool_choice": "auto", "input": [ { "type": "mcp_approval_response", @@ -1474,7 +1467,6 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) "require_approval": "never" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -1739,7 +1731,6 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() "require_approval": "never" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -2610,7 +2601,6 @@ public async Task CodeInterpreterTool_NonStreaming() "role":"user", "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 5"}] }], - "tool_choice":"auto", "tools":[{ "type":"code_interpreter", "container":{"type":"auto"} @@ -2739,7 +2729,6 @@ public async Task CodeInterpreterTool_Streaming() "role":"user", "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 10 using Python"}] }], - "tool_choice":"auto", "tools":[{ "type":"code_interpreter", "container":{"type":"auto"} @@ -3032,7 +3021,7 @@ public async Task ConversationId_AsConversationId_NonStreaming() { "temperature":0.5, "model":"gpt-4o-mini", - "conversation":"conv_12345", + "conversation":{"id":"conv_12345"}, "input": [{ "type":"message", "role":"user", @@ -3134,7 +3123,7 @@ public async Task ConversationId_WhenStoreDisabled_ReturnsNull_NonStreaming() { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = false } @@ -3196,7 +3185,7 @@ public async Task ConversationId_ChatOptionsOverridesRawRepresentationResponseId MaxOutputTokens = 20, Temperature = 0.5f, ConversationId = "resp_override", - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { PreviousResponseId = null } @@ -3258,7 +3247,7 @@ public async Task ConversationId_RawRepresentationPreviousResponseIdTakesPrecede MaxOutputTokens = 20, Temperature = 0.5f, ConversationId = "conv_ignored", - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { PreviousResponseId = "resp_fromraw" } @@ -3320,7 +3309,7 @@ public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_NonStrea { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = true } @@ -3394,7 +3383,7 @@ public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_Streamin { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = true } @@ -3475,7 +3464,7 @@ public async Task ConversationId_WhenStoreDisabled_ReturnsNull_Streaming() { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = false } @@ -3500,7 +3489,7 @@ public async Task ConversationId_AsConversationId_Streaming() { "temperature":0.5, "model":"gpt-4o-mini", - "conversation":"conv_12345", + "conversation":{"id":"conv_12345"}, "input":[ { "type":"message", @@ -3656,7 +3645,7 @@ public async Task ConversationId_RawRepresentationConversationIdTakesPrecedence_ { "temperature":0.5, "model":"gpt-4o-mini", - "conversation":"conv_12345", + "conversation":{"id":"conv_12345"}, "input": [{ "type":"message", "role":"user", @@ -3695,20 +3684,15 @@ public async Task ConversationId_RawRepresentationConversationIdTakesPrecedence_ using HttpClient httpClient = new(handler); using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); - var rcoJsonModel = (IJsonModel)new ResponseCreationOptions(); - BinaryData rcoJsonBinaryData = rcoJsonModel.Write(ModelReaderWriterOptions.Json); - JsonObject rcoJsonObject = Assert.IsType(JsonNode.Parse(rcoJsonBinaryData.ToMemory().Span)); - Assert.Null(rcoJsonObject["conversation"]); - rcoJsonObject["conversation"] = "conv_12345"; - var response = await client.GetResponseAsync("hello", new() { MaxOutputTokens = 20, Temperature = 0.5f, ConversationId = "conv_ignored", - RawRepresentationFactory = (c) => rcoJsonModel.Create( - new BinaryData(JsonSerializer.SerializeToUtf8Bytes(rcoJsonObject)), - ModelReaderWriterOptions.Json) + RawRepresentationFactory = _ => new CreateResponseOptions + { + ConversationOptions = new("conv_12345"), + } }); Assert.NotNull(response); @@ -5146,7 +5130,6 @@ public async Task HostedImageGenerationTool_NonStreaming() "output_format": "png" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -5240,7 +5223,6 @@ public async Task HostedImageGenerationTool_Streaming() "output_format": "png" } ], - "tool_choice": "auto", "stream": true, "input": [ { @@ -5352,7 +5334,6 @@ public async Task HostedImageGenerationTool_StreamingMultipleImages() "partial_images": 3 } ], - "tool_choice": "auto", "stream": true, "input": [ { @@ -5486,7 +5467,7 @@ private static IChatClient CreateResponseClient(HttpClient httpClient, string mo new OpenAIClient( new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) - .GetOpenAIResponseClient(modelId) + .GetResponsesClient(modelId) .AsIChatClient(); private static string ResponseStatusToRequestValue(ResponseStatus status) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs index d7e25135462..4bfb1ca7796 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs @@ -17,8 +17,8 @@ var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. -var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning disable OPENAI001 // GetResponsesClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetResponsesClient("gpt-4o-mini").AsIChatClient(); #pragma warning restore OPENAI001 var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();