From 011edfe420e47b92d2ea97052d56bb3b9d7c2fac Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 20 Aug 2025 16:12:12 +0100
Subject: [PATCH] .NET: Bring OpenAIResponsesChatClient temporarily (#449)
* copy OpenAIResponsesChatClient from meai temporarly
* Update dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* add AsNewIChatClient extension method
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
dotnet/samples/GettingStarted/AgentSample.cs | 3 +-
.../NewOpenAIResponsesChatClient.cs | 898 ++++++++++++++++++
.../OpenAIResponseClientExtensions.cs | 11 +-
.../OpenAIResponse.IntegrationTests.csproj | 1 +
.../OpenAIResponseFixture.cs | 2 +-
5 files changed, 912 insertions(+), 3 deletions(-)
create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs
diff --git a/dotnet/samples/GettingStarted/AgentSample.cs b/dotnet/samples/GettingStarted/AgentSample.cs
index 79f9dc8b8a..5c23370b85 100644
--- a/dotnet/samples/GettingStarted/AgentSample.cs
+++ b/dotnet/samples/GettingStarted/AgentSample.cs
@@ -8,6 +8,7 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
using Microsoft.Shared.Samples;
+using OpenAI;
using OpenAI.Assistants;
using OpenAI.Chat;
using OpenAI.Responses;
@@ -110,7 +111,7 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output)
private IChatClient GetOpenAIResponsesClient()
=> new OpenAIResponseClient(TestConfiguration.OpenAI.ChatModelId, TestConfiguration.OpenAI.ApiKey)
- .AsIChatClient();
+ .AsNewIChatClient();
private NewPersistentAgentsChatClient GetAzureAIAgentPersistentClient(ChatClientAgentOptions options)
=> new(new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential()), options.Id!);
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs
new file mode 100644
index 0000000000..2e6051231a
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs
@@ -0,0 +1,898 @@
+#pragma warning disable IDE0073 // The file header does not match the required text
+#pragma warning disable CA1063 // Implement IDisposable Correctly
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ClientModel.Primitives;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using Microsoft.Shared.Diagnostics;
+using OpenAI.Responses;
+
+#pragma warning disable S907 // "goto" statement should not be used
+#pragma warning disable S1067 // Expressions should not be too complex
+#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
+#pragma warning disable S3604 // Member initializer values should not be redundant
+#pragma warning disable SA1202 // Elements should be ordered by access
+#pragma warning disable SA1204 // Static elements should appear before instance elements
+
+namespace Microsoft.Extensions.AI;
+
+/// Represents an for an .
+internal sealed class NewOpenAIResponsesChatClient : IChatClient
+{
+ /// Metadata about the client.
+ private readonly ChatClientMetadata _metadata;
+
+ /// The underlying .
+ private readonly OpenAIResponseClient _responseClient;
+
+ /// Initializes a new instance of the class for the specified .
+ /// The underlying client.
+ /// is .
+ public NewOpenAIResponsesChatClient(OpenAIResponseClient responseClient)
+ {
+ _ = Throw.IfNull(responseClient);
+
+ _responseClient = responseClient;
+
+ // https://github.com/openai/openai-dotnet/issues/215
+ // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages
+ // implement the abstractions directly rather than providing adapters on top of the public APIs,
+ // the package can provide such implementations separate from what's exposed in the public API.
+ Uri providerUrl = typeof(OpenAIResponseClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
+ ?.GetValue(responseClient) as Uri ?? OpenAIClientExtensions3.DefaultOpenAIEndpoint;
+ string? model = typeof(OpenAIResponseClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
+ ?.GetValue(responseClient) as string;
+
+ _metadata = new("openai", providerUrl, model);
+ }
+
+ ///
+ object? IChatClient.GetService(Type serviceType, object? serviceKey)
+ {
+ _ = Throw.IfNull(serviceType);
+
+ return
+ serviceKey is not null ? null :
+ serviceType == typeof(ChatClientMetadata) ? _metadata :
+ serviceType == typeof(OpenAIResponseClient) ? _responseClient :
+ serviceType.IsInstanceOfType(this) ? this :
+ null;
+ }
+
+ ///
+ public async Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(messages);
+
+ // Convert the inputs into what OpenAIResponseClient expects.
+ var openAIResponseItems = ToOpenAIResponseItems(messages, options);
+ var openAIOptions = ToOpenAIResponseCreationOptions(options);
+
+ // Make the call to the OpenAIResponseClient.
+ var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value;
+
+ // Convert the response to a ChatResponse.
+ return FromOpenAIResponse(openAIResponse, openAIOptions);
+ }
+
+ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions)
+ {
+ // Convert and return the results.
+ ChatResponse response = new()
+ {
+ ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id,
+ CreatedAt = openAIResponse.CreatedAt,
+ FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason),
+ ModelId = openAIResponse.Model,
+ RawRepresentation = openAIResponse,
+ ResponseId = openAIResponse.Id,
+ Usage = ToUsageDetails(openAIResponse),
+ };
+
+ if (!string.IsNullOrEmpty(openAIResponse.EndUserId))
+ {
+ (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId;
+ }
+
+ if (openAIResponse.Error is not null)
+ {
+ (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error;
+ }
+
+ if (openAIResponse.OutputItems is not null)
+ {
+ response.Messages = [.. ToChatMessages(openAIResponse.OutputItems)];
+
+ if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error)
+ {
+ lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() });
+ }
+
+ foreach (var message in response.Messages)
+ {
+ message.CreatedAt ??= openAIResponse.CreatedAt;
+ }
+ }
+
+ return response;
+ }
+
+ internal static IEnumerable ToChatMessages(IEnumerable items)
+ {
+ ChatMessage? message = null;
+
+ foreach (ResponseItem outputItem in items)
+ {
+ message ??= new(ChatRole.Assistant, (string?)null);
+
+ switch (outputItem)
+ {
+ case MessageResponseItem messageItem:
+ if (message.MessageId is not null && message.MessageId != messageItem.Id)
+ {
+ yield return message;
+ message = new ChatMessage();
+ }
+
+ message.MessageId = messageItem.Id;
+ message.RawRepresentation = messageItem;
+ message.Role = ToChatRole(messageItem.Role);
+ ((List)message.Contents).AddRange(ToAIContents(messageItem.Content));
+ break;
+
+ case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary:
+ message.Contents.Add(new TextReasoningContent(summary) { RawRepresentation = outputItem });
+ break;
+
+ case FunctionCallResponseItem functionCall:
+ var fcc = OpenAIClientExtensions3.ParseCallContent(functionCall.FunctionArguments, functionCall.CallId, functionCall.FunctionName);
+ fcc.RawRepresentation = outputItem;
+ message.Contents.Add(fcc);
+ break;
+
+ case FunctionCallOutputResponseItem functionCallOutputItem:
+ message.Contents.Add(new FunctionResultContent(functionCallOutputItem.CallId, functionCallOutputItem.FunctionOutput) { RawRepresentation = functionCallOutputItem });
+ break;
+
+ default:
+ message.Contents.Add(new() { RawRepresentation = outputItem });
+ break;
+ }
+ }
+
+ if (message is not null)
+ {
+ yield return message;
+ }
+ }
+
+ ///
+ public IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(messages);
+
+ var openAIResponseItems = ToOpenAIResponseItems(messages, options);
+ var openAIOptions = ToOpenAIResponseCreationOptions(options);
+
+ var streamingUpdates = _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken);
+
+ return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken);
+ }
+
+ internal static async IAsyncEnumerable FromOpenAIStreamingResponseUpdatesAsync(
+ IAsyncEnumerable streamingResponseUpdates, ResponseCreationOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ DateTimeOffset? createdAt = null;
+ string? responseId = null;
+ string? conversationId = null;
+ string? modelId = null;
+ string? lastMessageId = null;
+ ChatRole? lastRole = null;
+ Dictionary outputIndexToMessages = [];
+ Dictionary? functionCallInfos = null;
+
+ await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ // Create an update populated with the current state of the response.
+ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
+ new(lastRole, content is not null ? [content] : null)
+ {
+ ConversationId = conversationId,
+ CreatedAt = createdAt,
+ MessageId = lastMessageId,
+ ModelId = modelId,
+ RawRepresentation = streamingUpdate,
+ ResponseId = responseId,
+ };
+
+ switch (streamingUpdate)
+ {
+ case StreamingResponseCreatedUpdate createdUpdate:
+ createdAt = createdUpdate.Response.CreatedAt;
+ responseId = createdUpdate.Response.Id;
+ conversationId = options?.StoredOutputEnabled is false ? null : responseId;
+ modelId = createdUpdate.Response.Model;
+ goto default;
+
+ case StreamingResponseCompletedUpdate completedUpdate:
+ {
+ var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null);
+ update.FinishReason =
+ ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ??
+ (functionCallInfos is not null ? ChatFinishReason.ToolCalls :
+ ChatFinishReason.Stop);
+ yield return update;
+ break;
+ }
+
+ case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate:
+ switch (outputItemAddedUpdate.Item)
+ {
+ case MessageResponseItem mri:
+ outputIndexToMessages[outputItemAddedUpdate.OutputIndex] = mri;
+ break;
+
+ case FunctionCallResponseItem fcri:
+ (functionCallInfos ??= [])[outputItemAddedUpdate.OutputIndex] = new(fcri);
+ break;
+ }
+
+ goto default;
+
+ case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate:
+ _ = outputIndexToMessages.Remove(outputItemDoneUpdate.OutputIndex);
+
+ if (outputItemDoneUpdate.Item is MessageResponseItem item &&
+ item.Content is { Count: > 0 } content &&
+ content.Any(c => c.OutputTextAnnotations is { Count: > 0 }))
+ {
+ AIContent annotatedContent = new();
+ foreach (var c in content)
+ {
+ PopulateAnnotations(c, annotatedContent);
+ }
+
+ yield return CreateUpdate(annotatedContent);
+ break;
+ }
+
+ goto default;
+
+ case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate:
+ {
+ _ = outputIndexToMessages.TryGetValue(outputTextDeltaUpdate.OutputIndex, out MessageResponseItem? messageItem);
+ lastMessageId = messageItem?.Id;
+ lastRole = ToChatRole(messageItem?.Role);
+
+ yield return CreateUpdate(new TextContent(outputTextDeltaUpdate.Delta));
+ break;
+ }
+
+ case StreamingResponseFunctionCallArgumentsDeltaUpdate functionCallArgumentsDeltaUpdate:
+ {
+ if (functionCallInfos?.TryGetValue(functionCallArgumentsDeltaUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true)
+ {
+ _ = (callInfo.Arguments ??= new()).Append(functionCallArgumentsDeltaUpdate.Delta);
+ }
+
+ goto default;
+ }
+
+ case StreamingResponseFunctionCallArgumentsDoneUpdate functionCallOutputDoneUpdate:
+ {
+ if (functionCallInfos?.TryGetValue(functionCallOutputDoneUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true)
+ {
+ _ = functionCallInfos.Remove(functionCallOutputDoneUpdate.OutputIndex);
+
+ var fcc = OpenAIClientExtensions3.ParseCallContent(
+ callInfo.Arguments?.ToString() ?? string.Empty,
+ callInfo.ResponseItem.CallId,
+ callInfo.ResponseItem.FunctionName);
+
+ lastMessageId = callInfo.ResponseItem.Id;
+ lastRole = ChatRole.Assistant;
+
+ yield return CreateUpdate(fcc);
+ break;
+ }
+
+ goto default;
+ }
+
+ case StreamingResponseErrorUpdate errorUpdate:
+ yield return CreateUpdate(new ErrorContent(errorUpdate.Message)
+ {
+ ErrorCode = errorUpdate.Code,
+ Details = errorUpdate.Param,
+ });
+ break;
+
+ case StreamingResponseRefusalDoneUpdate refusalDone:
+ yield return CreateUpdate(new ErrorContent(refusalDone.Refusal)
+ {
+ ErrorCode = nameof(ResponseContentPart.Refusal),
+ });
+ break;
+
+ default:
+ yield return CreateUpdate();
+ break;
+ }
+ }
+ }
+
+ ///
+ void IDisposable.Dispose()
+ {
+ // Nothing to dispose. Implementation required for the IChatClient interface.
+ }
+
+ internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null)
+ {
+ bool? strict =
+ OpenAIClientExtensions3.HasStrict(aiFunction.AdditionalProperties) ??
+ OpenAIClientExtensions3.HasStrict(options?.AdditionalProperties);
+
+ return ResponseTool.CreateFunctionTool(
+ aiFunction.Name,
+ aiFunction.Description,
+ OpenAIClientExtensions3.ToOpenAIFunctionParameters(aiFunction, strict),
+ strict ?? false);
+ }
+
+ /// Creates a from a .
+ private static ChatRole ToChatRole(OpenAI.Responses.MessageRole? role) =>
+ role switch
+ {
+ OpenAI.Responses.MessageRole.System => ChatRole.System,
+ OpenAI.Responses.MessageRole.Developer => OpenAIClientExtensions3.ChatRoleDeveloper,
+ OpenAI.Responses.MessageRole.User => ChatRole.User,
+ _ => ChatRole.Assistant,
+ };
+
+ /// Creates a from a .
+ private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) =>
+ statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter :
+ statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length :
+ null;
+
+ /// Converts a to a .
+ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options)
+ {
+ if (options is null)
+ {
+ return new ResponseCreationOptions();
+ }
+
+ if (options.RawRepresentationFactory?.Invoke(this) is not ResponseCreationOptions result)
+ {
+ result = new ResponseCreationOptions();
+ }
+
+ // Handle strongly-typed properties.
+ result.MaxOutputTokenCount ??= options.MaxOutputTokens;
+ result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls;
+ result.PreviousResponseId ??= options.ConversationId;
+ result.Temperature ??= options.Temperature;
+ result.TopP ??= options.TopP;
+
+ if (options.Instructions is { } instructions)
+ {
+ result.Instructions = string.IsNullOrEmpty(result.Instructions) ?
+ instructions :
+ $"{result.Instructions}{Environment.NewLine}{instructions}";
+ }
+
+ // Populate tools if there are any.
+ if (options.Tools is { Count: > 0 } tools)
+ {
+ foreach (AITool tool in tools)
+ {
+ switch (tool)
+ {
+ case AIFunction aiFunction:
+ result.Tools.Add(ToResponseTool(aiFunction, options));
+ break;
+
+ case HostedWebSearchTool webSearchTool:
+ WebSearchUserLocation? location = null;
+ if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchUserLocation), out object? objLocation))
+ {
+ location = objLocation as WebSearchUserLocation;
+ }
+
+ WebSearchContextSize? size = null;
+ if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchContextSize), out object? objSize) &&
+ objSize is WebSearchContextSize)
+ {
+ size = (WebSearchContextSize)objSize;
+ }
+
+ result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size));
+ break;
+
+ case HostedFileSearchTool fileSearchTool:
+ result.Tools.Add(ResponseTool.CreateFileSearchTool(
+ fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [],
+ fileSearchTool.MaximumResultCount));
+ break;
+
+ case HostedCodeInterpreterTool codeTool:
+ string json;
+ if (codeTool.Inputs is { Count: > 0 } inputs)
+ {
+ string jsonArray = JsonSerializer.Serialize(
+ inputs.OfType().Select(c => c.FileId),
+ OpenAIJsonContext2.Default.IEnumerableString);
+ json = $$"""{"type":"code_interpreter","container":{"type":"auto",files:{{jsonArray}}} }""";
+ }
+ else
+ {
+ json = """{"type":"code_interpreter","container":"auto"}""";
+ }
+
+ result.Tools.Add(ModelReaderWriter.Read(BinaryData.FromString(json)));
+ break;
+ }
+ }
+
+ if (result.ToolChoice is null && result.Tools.Count > 0)
+ {
+ switch (options.ToolMode)
+ {
+ case NoneChatToolMode:
+ result.ToolChoice = ResponseToolChoice.CreateNoneChoice();
+ break;
+
+ case AutoChatToolMode:
+ case null:
+ result.ToolChoice = ResponseToolChoice.CreateAutoChoice();
+ break;
+
+ case RequiredChatToolMode required:
+ result.ToolChoice = required.RequiredFunctionName is not null ?
+ ResponseToolChoice.CreateFunctionChoice(required.RequiredFunctionName) :
+ ResponseToolChoice.CreateRequiredChoice();
+ break;
+ }
+ }
+ }
+
+ if (result.TextOptions is null)
+ {
+ if (options.ResponseFormat is ChatResponseFormatText)
+ {
+ result.TextOptions = new()
+ {
+ TextFormat = ResponseTextFormat.CreateTextFormat()
+ };
+ }
+ else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat)
+ {
+ result.TextOptions = new()
+ {
+ TextFormat = OpenAIClientExtensions3.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
+ ResponseTextFormat.CreateJsonSchemaFormat(
+ jsonFormat.SchemaName ?? "json_schema",
+ BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext2.Default.JsonElement)),
+ jsonFormat.SchemaDescription,
+ OpenAIClientExtensions3.HasStrict(options.AdditionalProperties)) :
+ ResponseTextFormat.CreateJsonObjectFormat(),
+ };
+ }
+ }
+
+ return result;
+ }
+
+ /// Convert a sequence of s to s.
+ internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs, ChatOptions? options)
+ {
+ _ = options; // currently unused
+
+ foreach (ChatMessage input in inputs)
+ {
+ if (input.Role == ChatRole.System ||
+ input.Role == OpenAIClientExtensions3.ChatRoleDeveloper)
+ {
+ string text = input.Text;
+ if (!string.IsNullOrWhiteSpace(text))
+ {
+ yield return input.Role == ChatRole.System ?
+ ResponseItem.CreateSystemMessageItem(text) :
+ ResponseItem.CreateDeveloperMessageItem(text);
+ }
+
+ continue;
+ }
+
+ if (input.Role == ChatRole.User)
+ {
+ yield return ResponseItem.CreateUserMessageItem(ToResponseContentParts(input.Contents));
+ continue;
+ }
+
+ if (input.Role == ChatRole.Tool)
+ {
+ foreach (AIContent item in input.Contents)
+ {
+ switch (item)
+ {
+ case AIContent when item.RawRepresentation is ResponseItem rawRep:
+ yield return rawRep;
+ break;
+
+ case FunctionResultContent resultContent:
+ string? result = resultContent.Result as string;
+ if (result is null && resultContent.Result is not null)
+ {
+ try
+ {
+ result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
+ }
+ catch (NotSupportedException)
+ {
+ // If the type can't be serialized, skip it.
+ }
+ }
+
+ yield return ResponseItem.CreateFunctionCallOutputItem(resultContent.CallId, result ?? string.Empty);
+ break;
+ }
+ }
+
+ continue;
+ }
+
+ if (input.Role == ChatRole.Assistant)
+ {
+ foreach (AIContent item in input.Contents)
+ {
+ switch (item)
+ {
+ case AIContent when item.RawRepresentation is ResponseItem rawRep:
+ yield return rawRep;
+ break;
+
+ case TextContent textContent:
+ yield return ResponseItem.CreateAssistantMessageItem(textContent.Text);
+ break;
+
+ case TextReasoningContent reasoningContent:
+ yield return ResponseItem.CreateReasoningItem(reasoningContent.Text);
+ break;
+
+ case FunctionCallContent callContent:
+ yield return ResponseItem.CreateFunctionCallItem(
+ callContent.CallId,
+ callContent.Name,
+ BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(
+ callContent.Arguments,
+ AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))));
+ break;
+ }
+ }
+
+ continue;
+ }
+ }
+ }
+
+ /// Extract usage details from an .
+ private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse)
+ {
+ UsageDetails? ud = null;
+ if (openAIResponse?.Usage is { } usage)
+ {
+ ud = new()
+ {
+ InputTokenCount = usage.InputTokenCount,
+ OutputTokenCount = usage.OutputTokenCount,
+ TotalTokenCount = usage.TotalTokenCount,
+ };
+
+ if (usage.InputTokenDetails is { } inputDetails)
+ {
+ ud.AdditionalCounts ??= [];
+ ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount);
+ }
+
+ if (usage.OutputTokenDetails is { } outputDetails)
+ {
+ ud.AdditionalCounts ??= [];
+ ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount);
+ }
+ }
+
+ return ud;
+ }
+
+ /// Convert a sequence of s to a list of .
+ private static List ToAIContents(IEnumerable contents)
+ {
+ List results = [];
+
+ foreach (ResponseContentPart part in contents)
+ {
+ switch (part.Kind)
+ {
+ case ResponseContentPartKind.InputText or ResponseContentPartKind.OutputText:
+ TextContent text = new(part.Text) { RawRepresentation = part };
+ PopulateAnnotations(part, text);
+ results.Add(text);
+ break;
+
+ case ResponseContentPartKind.InputFile:
+ if (!string.IsNullOrWhiteSpace(part.InputImageFileId))
+ {
+ results.Add(new HostedFileContent(part.InputImageFileId) { RawRepresentation = part });
+ }
+ else if (!string.IsNullOrWhiteSpace(part.InputFileId))
+ {
+ results.Add(new HostedFileContent(part.InputFileId) { RawRepresentation = part });
+ }
+ else if (part.InputFileBytes is not null)
+ {
+ results.Add(new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream")
+ {
+ Name = part.InputFilename,
+ RawRepresentation = part,
+ });
+ }
+
+ break;
+
+ case ResponseContentPartKind.Refusal:
+ results.Add(new ErrorContent(part.Refusal)
+ {
+ ErrorCode = nameof(ResponseContentPartKind.Refusal),
+ RawRepresentation = part,
+ });
+ break;
+
+ default:
+ results.Add(new() { RawRepresentation = part });
+ break;
+ }
+ }
+
+ return results;
+ }
+
+ /// Converts any annotations from and stores them in .
+ private static void PopulateAnnotations(ResponseContentPart source, AIContent destination)
+ {
+ if (source.OutputTextAnnotations is { Count: > 0 })
+ {
+ foreach (var ota in source.OutputTextAnnotations)
+ {
+ (destination.Annotations ??= []).Add(new CitationAnnotation
+ {
+ RawRepresentation = ota,
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ota.UriCitationStartIndex, EndIndex = ota.UriCitationEndIndex }],
+ Title = ota.UriCitationTitle,
+ Url = ota.UriCitationUri,
+ FileId = ota.FileCitationFileId ?? ota.FilePathFileId,
+ });
+ }
+ }
+ }
+
+ /// Convert a list of s to a list of .
+ private static List ToResponseContentParts(IList contents)
+ {
+ List parts = [];
+ foreach (var content in contents)
+ {
+ switch (content)
+ {
+ case AIContent when content.RawRepresentation is ResponseContentPart rawRep:
+ parts.Add(rawRep);
+ break;
+
+ case TextContent textContent:
+ parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text));
+ break;
+
+ case UriContent uriContent when uriContent.HasTopLevelMediaType("image"):
+ parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri));
+ break;
+
+ case DataContent dataContent when dataContent.HasTopLevelMediaType("image"):
+ parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType));
+ break;
+
+ case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase):
+ parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"));
+ break;
+
+ case HostedFileContent fileContent:
+ parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId));
+ break;
+
+ case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal):
+ parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message));
+ break;
+ }
+ }
+
+ if (parts.Count == 0)
+ {
+ parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty));
+ }
+
+ return parts;
+ }
+
+ /// POCO representing function calling info.
+ /// Used to concatenation information for a single function call from across multiple streaming updates.
+ private sealed class FunctionCallInfo(FunctionCallResponseItem item)
+ {
+ public readonly FunctionCallResponseItem ResponseItem = item;
+ public StringBuilder? Arguments;
+ }
+}
+
+internal static class OpenAIClientExtensions3
+{
+ /// Key into AdditionalProperties used to store a strict option.
+ private const string StrictKey = "strictJsonSchema";
+
+ /// Gets the default OpenAI endpoint.
+ internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");
+
+ /// Gets a for "developer".
+ internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");
+
+ ///
+ /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per
+ /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
+ ///
+ internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
+ {
+ DisallowAdditionalProperties = true,
+ ConvertBooleanSchemas = true,
+ MoveDefaultKeywordToDescription = true,
+ RequireAllProperties = true,
+ TransformSchemaNode = (ctx, node) =>
+ {
+ // Move content from common but unsupported properties to description. In particular, we focus on properties that
+ // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation.
+
+ if (node is JsonObject schemaObj)
+ {
+ StringBuilder? additionalDescription = null;
+
+ ReadOnlySpan unsupportedProperties =
+ [
+ // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties:
+ "contentEncoding", "contentMediaType", "not",
+
+ // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models:
+ "minLength", "maxLength", "pattern", "format",
+ "minimum", "maximum", "multipleOf",
+ "patternProperties",
+ "minItems", "maxItems",
+
+ // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords
+ // as being unsupported with Azure OpenAI:
+ "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties",
+ "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems",
+ ];
+
+ foreach (string propName in unsupportedProperties)
+ {
+ if (schemaObj[propName] is { } propNode)
+ {
+ _ = schemaObj.Remove(propName);
+ AppendLine(ref additionalDescription, propName, propNode);
+ }
+ }
+
+ if (additionalDescription is not null)
+ {
+ schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ?
+ $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" :
+ additionalDescription.ToString();
+ }
+
+ return node;
+
+ static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode)
+ {
+ sb ??= new();
+
+ if (sb.Length > 0)
+ {
+ _ = sb.AppendLine();
+ }
+
+ _ = sb.Append(propName).Append(": ").Append(propNode);
+ }
+ }
+
+ return node;
+ },
+ });
+
+ /// Gets whether the properties specify that strict schema handling is desired.
+ internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) =>
+ additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true &&
+ strictObj is bool strictValue ?
+ strictValue : null;
+
+ /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs.
+ internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict)
+ {
+ // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting.
+ JsonElement jsonSchema = strict is true ?
+ StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
+ aiFunction.JsonSchema;
+
+ // Roundtrip the schema through the ToolJson model type to remove extra properties
+ // and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData.
+ var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext2.Default.ToolJson)!;
+ var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext2.Default.ToolJson));
+
+ return functionParameters;
+ }
+
+ /// Creates a new instance of parsing arguments using a specified encoding and parser.
+ /// The input arguments to be parsed.
+ /// The function call ID.
+ /// The function name.
+ /// A new instance of containing the parse result.
+ /// is .
+ /// is .
+ internal static FunctionCallContent ParseCallContent(string json, string callId, string name) =>
+ FunctionCallContent.CreateFromParsedArguments(json, callId, name,
+ static json => JsonSerializer.Deserialize(json, OpenAIJsonContext2.Default.IDictionaryStringObject)!);
+
+ /// Creates a new instance of parsing arguments using a specified encoding and parser.
+ /// The input arguments to be parsed.
+ /// The function call ID.
+ /// The function name.
+ /// A new instance of containing the parse result.
+ /// is .
+ /// is .
+ internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) =>
+ FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name,
+ static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext2.Default.IDictionaryStringObject)!);
+
+ /// Used to create the JSON payload for an OpenAI tool description.
+ internal sealed class ToolJson
+ {
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "object";
+
+ [JsonPropertyName("required")]
+ public HashSet Required { get; set; } = [];
+
+ [JsonPropertyName("properties")]
+ public Dictionary Properties { get; set; } = [];
+
+ [JsonPropertyName("additionalProperties")]
+ public bool AdditionalProperties { get; set; }
+ }
+}
+
+/// Source-generated JSON type information for use by all OpenAI implementations.
+[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
+ UseStringEnumConverter = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = true)]
+[JsonSerializable(typeof(OpenAIClientExtensions3.ToolJson))]
+[JsonSerializable(typeof(IDictionary))]
+[JsonSerializable(typeof(string[]))]
+[JsonSerializable(typeof(IEnumerable))]
+[JsonSerializable(typeof(JsonElement))]
+internal sealed partial class OpenAIJsonContext2 : JsonSerializerContext;
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIResponseClientExtensions.cs
index 37f27f3047..c387be1e5c 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIResponseClientExtensions.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIResponseClientExtensions.cs
@@ -60,8 +60,17 @@ public static class OpenAIResponseClientExtensions
Throw.IfNull(client);
Throw.IfNull(options);
- var chatClient = client.AsIChatClient();
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ var chatClient = client.AsNewIChatClient();
+#pragma warning restore CA2000 // Dispose objects before losing scope
ChatClientAgent agent = new(chatClient, options, loggerFactory);
return agent;
}
+
+ /// Gets an for use with this .
+ /// The client.
+ /// An that can be used to converse via the .
+ /// is .
+ public static IChatClient AsNewIChatClient(this OpenAIResponseClient responseClient) =>
+ new NewOpenAIResponsesChatClient(responseClient);
}
diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj
index df161719c9..60377c88ec 100644
--- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj
+++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs
index 77b964559b..304a955b68 100644
--- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs
+++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs
@@ -70,7 +70,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
IList? aiTools = null)
{
return Task.FromResult(new ChatClientAgent(
- this._openAIResponseClient.AsIChatClient(),
+ this._openAIResponseClient.AsNewIChatClient(),
options: new()
{
Name = name,