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,