File: OpenAIResponseClientIntegrationTests.cs
Project: ..\..\..\test\Libraries\Microsoft.Extensions.AI.OpenAI.Tests\Microsoft.Extensions.AI.OpenAI.Tests.csproj (Microsoft.Extensions.AI.OpenAI.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using OpenAI.Responses;
using Xunit;
 
#pragma warning disable OPENAI001 // Experimental OpenAI APIs
 
namespace Microsoft.Extensions.AI;
 
public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests
{
    protected override IChatClient? CreateChatClient() =>
        IntegrationTestHelpers.GetOpenAIClient()
        ?.GetResponsesClient()
        .AsIChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini");
 
    private static string ReasoningModel => "gpt-5-nano";
 
    public override bool FunctionInvokingChatClientSetsConversationId => true;
 
    // Test structure doesn't make sense with Responses.
    public override Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() => Task.CompletedTask;
 
    [Fact]
    public async Task UseCodeInterpreter_ProducesCodeExecutionResults()
    {
        SkipIfNotEnabled();
 
        var response = await ChatClient.GetResponseAsync("Use the code interpreter to calculate the square root of 42. Return only the nearest integer value and no other text.", new()
        {
            Tools = [new HostedCodeInterpreterTool()],
        });
        Assert.NotNull(response);
 
        ChatMessage message = Assert.Single(response.Messages);
 
        Assert.Equal("6", message.Text);
 
        // Validate CodeInterpreterToolCallContent
        var toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType<CodeInterpreterToolCallContent>().SingleOrDefault();
        Assert.NotNull(toolCallContent);
        Assert.NotNull(toolCallContent.CallId);
        Assert.NotEmpty(toolCallContent.CallId);
        Assert.NotNull(toolCallContent.Inputs);
        Assert.NotEmpty(toolCallContent.Inputs);
 
        var codeInput = toolCallContent.Inputs.OfType<DataContent>().FirstOrDefault();
        Assert.NotNull(codeInput);
        Assert.Equal("text/x-python", codeInput.MediaType);
        Assert.NotEmpty(codeInput.Data.ToArray());
 
        // Validate CodeInterpreterToolResultContent
        var toolResultContent = response.Messages.SelectMany(m => m.Contents).OfType<CodeInterpreterToolResultContent>().FirstOrDefault();
        Assert.NotNull(toolResultContent);
        Assert.NotNull(toolResultContent.CallId);
        Assert.NotEmpty(toolResultContent.CallId);
 
        if (toolResultContent.Outputs is not null)
        {
            Assert.NotEmpty(toolResultContent.Outputs);
            if (toolResultContent.Outputs.OfType<TextContent>().FirstOrDefault() is { } resultOutput)
            {
                Assert.NotEmpty(resultOutput.Text);
            }
        }
    }
 
    [Fact]
    public async Task UseWebSearch_AnnotationsReflectResults()
    {
        SkipIfNotEnabled();
 
        var response = await ChatClient.GetResponseAsync(
            "Write a paragraph about .NET based on at least three recent news articles. Cite your sources.",
            new()
            {
                Tools = [new HostedWebSearchTool()],
                RawRepresentationFactory = _ =>
                {
                    var cro = new CreateResponseOptions();
                    cro.IncludedProperties.Add("web_search_call.action.sources");
                    return cro;
                },
            });
 
        ChatMessage m = Assert.Single(response.Messages);
 
        // Verify that the web search tool call and result content are present.
        var wsCall = m.Contents.OfType<WebSearchToolCallContent>().FirstOrDefault();
        Assert.NotNull(wsCall);
        Assert.NotNull(wsCall.CallId);
        Assert.NotNull(wsCall.Queries);
        Assert.NotEmpty(wsCall.Queries);
 
        var wsResult = m.Contents.OfType<WebSearchToolResultContent>().FirstOrDefault();
        Assert.NotNull(wsResult);
        Assert.Equal(wsCall.CallId, wsResult.CallId);
 
        // Verify that sources are populated when opted in.
        Assert.NotNull(wsResult.Outputs);
        Assert.NotEmpty(wsResult.Outputs);
        Assert.All(wsResult.Outputs, r =>
        {
            var uriContent = Assert.IsType<UriContent>(r);
            Assert.NotNull(uriContent.Uri);
        });
 
        TextContent tc = m.Contents.OfType<TextContent>().First();
        Assert.NotNull(tc.Annotations);
        Assert.NotEmpty(tc.Annotations);
        Assert.All(tc.Annotations, a =>
        {
            CitationAnnotation ca = Assert.IsType<CitationAnnotation>(a);
            var regions = Assert.IsType<List<AnnotatedRegion>>(ca.AnnotatedRegions);
            Assert.NotNull(regions);
            Assert.Single(regions);
            var region = Assert.IsType<TextSpanAnnotatedRegion>(regions[0]);
            Assert.NotNull(region);
            Assert.NotNull(region.StartIndex);
            Assert.NotNull(region.EndIndex);
            Assert.NotNull(ca.Url);
            Assert.NotNull(ca.Title);
            Assert.NotEmpty(ca.Title);
        });
    }
 
    [Theory]
    [InlineData(false, "gpt-image-1-mini")]
    [InlineData(true, "gpt-image-2")]
    public async Task UseImageGeneration_ProducesImageContent(bool streaming, string imageModel)
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Image generation tool requires gpt-5.4 or later.");
 
        var chatOptions = new ChatOptions
        {
            Tools =
            [
                new HostedImageGenerationTool
                {
                    Options = new ImageGenerationOptions { ModelId = imageModel },
                },
            ],
        };
 
        ChatResponse response = streaming
            ? await ChatClient.GetStreamingResponseAsync("Generate an image of a simple blue circle on a white background.", chatOptions).ToChatResponseAsync()
            : await ChatClient.GetResponseAsync("Generate an image of a simple blue circle on a white background.", chatOptions);
 
        Assert.NotNull(response);
 
        ChatMessage m = Assert.Single(response.Messages);
 
        // Verify that the image generation tool call and result content are present.
        var igCall = m.Contents.OfType<ImageGenerationToolCallContent>().FirstOrDefault();
        Assert.NotNull(igCall);
        Assert.NotNull(igCall.CallId);
 
        var igResult = m.Contents.OfType<ImageGenerationToolResultContent>().FirstOrDefault();
        Assert.NotNull(igResult);
        Assert.Equal(igCall.CallId, igResult.CallId);
 
        // Verify that the result contains image data.
        Assert.NotNull(igResult.Outputs);
        Assert.NotEmpty(igResult.Outputs);
        var imageContent = Assert.Single(igResult.Outputs.OfType<DataContent>());
        Assert.False(imageContent.Data.IsEmpty);
        Assert.StartsWith("image/", imageContent.MediaType, StringComparison.Ordinal);
 
        // Save to temp file for manual inspection.
        string extension = imageContent.MediaType == "image/png" ? ".png" : ".webp";
        string tempPath = Path.Combine(Path.GetTempPath(), $"image_gen_test_{imageModel}{extension}");
        File.WriteAllBytes(tempPath, imageContent.Data.ToArray());
    }
 
    [Fact]
    public async Task RemoteMCP_ListTools()
    {
        SkipIfNotEnabled();
 
        ChatOptions chatOptions = new()
        {
            Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) { ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire }],
        };
 
        ChatResponse response = await CreateChatClient()!.GetResponseAsync("Which tools are available on the wiki_tools MCP server?", chatOptions);
 
        Assert.Contains("read_wiki_structure", response.Text);
        Assert.Contains("read_wiki_contents", response.Text);
        Assert.Contains("ask_question", response.Text);
    }
 
    [Fact]
    public async Task RemoteMCP_CallTool_ApprovalNeverRequired()
    {
        SkipIfNotEnabled();
 
        await RunAsync(false, false);
        await RunAsync(true, true);
 
        async Task RunAsync(bool streaming, bool requireSpecific)
        {
            ChatOptions chatOptions = new()
            {
                Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))
                    {
                        ApprovalMode = requireSpecific ?
                            HostedMcpServerToolApprovalMode.RequireSpecific(null, ["read_wiki_structure", "ask_question"]) :
                            HostedMcpServerToolApprovalMode.NeverRequire,
                    }
                ],
            };
 
            using var client = CreateChatClient()!;
 
            const string Prompt = "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository";
 
            ChatResponse response = streaming ?
                await client.GetStreamingResponseAsync(Prompt, chatOptions).ToChatResponseAsync() :
                await client.GetResponseAsync(Prompt, chatOptions);
 
            Assert.NotNull(response);
            Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolCallContent>());
            Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolResultContent>());
            Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>());
 
            Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text);
        }
    }
 
    [Fact]
    public async Task RemoteMCP_CallTool_ApprovalRequired()
    {
        SkipIfNotEnabled();
 
        await RunAsync(false, false, false);
        await RunAsync(true, true, false);
        await RunAsync(false, false, true);
        await RunAsync(true, true, true);
 
        async Task RunAsync(bool streaming, bool requireSpecific, bool useConversationId)
        {
            ChatOptions chatOptions = new()
            {
                Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))
                    {
                        ApprovalMode = requireSpecific ?
                            HostedMcpServerToolApprovalMode.RequireSpecific(["read_wiki_structure", "ask_question"], null) :
                            HostedMcpServerToolApprovalMode.AlwaysRequire,
                    }
                ],
            };
 
            using var client = CreateChatClient()!;
 
            // Initial request
            List<ChatMessage> input = [new ChatMessage(ChatRole.User, "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository")];
            ChatResponse response = streaming ?
                await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() :
                await client.GetResponseAsync(input, chatOptions);
 
            // Handle approvals of up to two rounds of tool calls
            int approvalsCount = 0;
            for (int i = 0; i < 2; i++)
            {
                if (useConversationId)
                {
                    chatOptions.ConversationId = response.ConversationId;
                    input.Clear();
                }
                else
                {
                    input.AddRange(response.Messages);
                }
 
                var approvalResponse = new ChatMessage(ChatRole.Tool,
                    response.Messages
                            .SelectMany(m => m.Contents)
                            .OfType<ToolApprovalRequestContent>()
                            .Select(c => c.CreateResponse(true))
                            .ToArray());
                if (approvalResponse.Contents.Count == 0)
                {
                    break;
                }
 
                approvalsCount += approvalResponse.Contents.Count;
                input.Add(approvalResponse);
                response = streaming ?
                    await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() :
                    await client.GetResponseAsync(input, chatOptions);
            }
 
            // Validate final response
            Assert.Equal(2, approvalsCount);
            Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text);
        }
    }
 
    [Fact]
    public async Task RemoteMCP_DeferLoadingTools()
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Tool search requires gpt-5.4 or later.");
 
        var mcpTool = new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))
        {
            ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire,
        };
 
        ChatOptions chatOptions = new()
        {
            Tools =
            [
                new HostedToolSearchTool(),
                mcpTool,
            ],
        };
 
        ChatResponse response = await ChatClient.GetResponseAsync(
            "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository",
            chatOptions);
 
        Assert.NotNull(response);
        Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text);
        Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolCallContent>());
        Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolResultContent>());
 
        // Verify tool_search response items are present via RawRepresentation,
        // since the OpenAI SDK doesn't expose dedicated types for them yet.
        var allContents = response.Messages.SelectMany(m => m.Contents).ToList();
        var rawJsons = allContents
            .Where(c => c.RawRepresentation is ResponseItem)
            .Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString())
            .ToList();
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\""));
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\""));
    }
 
    [Fact]
    public async Task GetResponseAsync_BackgroundResponses()
    {
        SkipIfNotEnabled();
 
        var chatOptions = new ChatOptions
        {
            AllowBackgroundResponses = true,
        };
 
        // Get initial response with continuation token
        var response = await ChatClient.GetResponseAsync("What's the biggest animal?", chatOptions);
        Assert.NotNull(response.ContinuationToken);
        Assert.Empty(response.Messages);
 
        int attempts = 0;
 
        // Continue to poll until we get the final response
        while (response.ContinuationToken is not null && ++attempts < 10)
        {
            chatOptions.ContinuationToken = response.ContinuationToken;
            response = await ChatClient.GetResponseAsync([], chatOptions);
            await Task.Delay(1000);
        }
 
        Assert.Contains("whale", response.Text, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task GetResponseAsync_BackgroundResponses_WithFunction()
    {
        SkipIfNotEnabled();
 
        int callCount = 0;
 
        using var chatClient = new FunctionInvokingChatClient(ChatClient);
 
        var chatOptions = new ChatOptions
        {
            AllowBackgroundResponses = true,
            Tools = [AIFunctionFactory.Create(() => { callCount++; return "5:43"; }, new AIFunctionFactoryOptions { Name = "GetCurrentTime" })]
        };
 
        // Get initial response with continuation token
        var response = await chatClient.GetResponseAsync("What time is it?", chatOptions);
        Assert.NotNull(response.ContinuationToken);
        Assert.Empty(response.Messages);
 
        int attempts = 0;
 
        // Poll until the result is received
        while (response.ContinuationToken is not null && ++attempts < 10)
        {
            chatOptions.ContinuationToken = response.ContinuationToken;
 
            response = await chatClient.GetResponseAsync([], chatOptions);
            await Task.Delay(1000);
        }
 
        Assert.Contains("5:43", response.Text, StringComparison.OrdinalIgnoreCase);
        Assert.Equal(1, callCount);
    }
 
    [Fact]
    public async Task GetStreamingResponseAsync_BackgroundResponses()
    {
        SkipIfNotEnabled();
 
        ChatOptions chatOptions = new()
        {
            AllowBackgroundResponses = true,
        };
 
        string responseText = "";
 
        await foreach (var update in ChatClient.GetStreamingResponseAsync("What is the capital of France?", chatOptions))
        {
            responseText += update;
        }
 
        // Assert
        Assert.Contains("Paris", responseText, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task GetStreamingResponseAsync_BackgroundResponses_StreamResumption()
    {
        SkipIfNotEnabled();
 
        ChatOptions chatOptions = new()
        {
            AllowBackgroundResponses = true,
        };
 
        int updateNumber = 0;
        string responseText = "";
        ResponseContinuationToken? continuationToken = null;
 
        await foreach (var update in ChatClient.GetStreamingResponseAsync("What is the capital of France?", chatOptions))
        {
            responseText += update;
 
            // Simulate an interruption after receiving 8 updates.
            if (updateNumber++ == 8)
            {
                continuationToken = update.ContinuationToken;
                break;
            }
        }
 
        Assert.DoesNotContain("Paris", responseText);
 
        // Resume streaming from the point of interruption captured by the continuation token.
        chatOptions.ContinuationToken = continuationToken;
        await foreach (var update in ChatClient.GetStreamingResponseAsync([], chatOptions))
        {
            responseText += update;
        }
 
        Assert.Contains("Paris", responseText, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task GetStreamingResponseAsync_BackgroundResponses_WithFunction()
    {
        SkipIfNotEnabled();
 
        int callCount = 0;
 
        using var chatClient = new FunctionInvokingChatClient(ChatClient);
 
        var chatOptions = new ChatOptions
        {
            AllowBackgroundResponses = true,
            Tools = [AIFunctionFactory.Create(() => { callCount++; return "5:43"; }, new AIFunctionFactoryOptions { Name = "GetCurrentTime" })]
        };
 
        string responseText = "";
 
        await foreach (var update in chatClient.GetStreamingResponseAsync("What time is it?", chatOptions))
        {
            responseText += update;
        }
 
        Assert.Contains("5:43", responseText);
        Assert.Equal(1, callCount);
    }
 
    [Fact]
    public async Task RemoteMCP_Connector()
    {
        SkipIfNotEnabled();
 
        if (TestRunnerConfiguration.Instance["RemoteMCP:ConnectorAccessToken"] is not string { Length: > 0 } accessToken)
        {
            Assert.Skip(
                "To run this test, set a value for RemoteMCP:ConnectorAccessToken. " +
                "You can obtain one by following https://platform.openai.com/docs/guides/tools-connectors-mcp?quickstart-panels=connector#authorizing-a-connector.");
            return; // Unreachable, but needed for definite assignment of 'accessToken'.
        }
 
        await RunAsync(false, false);
        await RunAsync(true, true);
 
        async Task RunAsync(bool streaming, bool approval)
        {
            ChatOptions chatOptions = new()
            {
                Tools = [new HostedMcpServerTool("calendar", "connector_googlecalendar")
                    {
                        ApprovalMode = approval ?
                            HostedMcpServerToolApprovalMode.AlwaysRequire :
                            HostedMcpServerToolApprovalMode.NeverRequire,
                        Headers = new Dictionary<string, string> { ["Authorization"] = $"Bearer {accessToken}" },
                    }
                ],
            };
 
            using var client = CreateChatClient()!;
 
            List<ChatMessage> input = [new ChatMessage(ChatRole.User, "What is on my calendar for today?")];
 
            ChatResponse response = streaming ?
                await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() :
                await client.GetResponseAsync(input, chatOptions);
 
            if (approval)
            {
                input.AddRange(response.Messages);
                var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>());
                var mcpCallContent = Assert.IsType<McpServerToolCallContent>(approvalRequest.ToolCall);
                Assert.Equal("search_events", mcpCallContent.Name);
                input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)]));
 
                response = streaming ?
                    await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() :
                    await client.GetResponseAsync(input, chatOptions);
            }
 
            Assert.NotNull(response);
            var toolCall = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolCallContent>());
            Assert.Equal("search_events", toolCall.Name);
 
            var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolResultContent>());
            var content = Assert.IsType<TextContent>(Assert.Single(toolResult.Outputs!));
            Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text);
        }
    }
 
    [Fact]
    public async Task ToolCallResult_TextContent()
    {
        SkipIfNotEnabled();
 
        var chatOptions = new ChatOptions
        {
            Tools = [AIFunctionFactory.Create((int a, int b) => new TextContent($"The sum is {a + b}"), "AddNumbers", "Adds two numbers together")]
        };
 
        using var client = new FunctionInvokingChatClient(ChatClient);
 
        var response = await client.GetResponseAsync("What is 25 plus 17? Use the AddNumbers function.", chatOptions);
 
        Assert.NotNull(response);
 
        // The model should have called the function and received "The sum is 42"
        Assert.Contains("42", response.Text);
    }
 
    [Fact]
    public async Task ToolCallResult_MultipleAIContents()
    {
        SkipIfNotEnabled();
 
        var chatOptions = new ChatOptions
        {
            Tools = [AIFunctionFactory.Create((string city) => new List<AIContent>
            {
                new TextContent($"Weather in {city}: "),
                new TextContent("Sunny, 72°F")
            }, "GetWeather", "Gets the weather for a city")]
        };
 
        using var client = new FunctionInvokingChatClient(ChatClient);
 
        var response = await client.GetResponseAsync("What's the weather in Seattle? Use GetWeather.", chatOptions);
 
        Assert.NotNull(response);
 
        // Verify the function was called and both parts were included
        var messages = response.Messages.ToList();
        Assert.NotEmpty(messages);
 
        // Check that we got a response mentioning the weather data
        Assert.Contains("72", response.Text);
    }
 
    [Fact]
    public async Task ToolCallResult_ImageDataContent()
    {
        SkipIfNotEnabled();
 
        var chatOptions = new ChatOptions
        {
            Tools = [AIFunctionFactory.Create(() => new DataContent(ImageDataUri.GetImageDataUri(), "image/png"), "GetDotnetLogo", "Returns the .NET logo image")]
        };
 
        using var client = new FunctionInvokingChatClient(ChatClient);
 
        var response = await client.GetResponseAsync("Call GetDotnetLogo and tell me what you see in the image.", chatOptions);
 
        Assert.NotNull(response);
 
        // The model should describe seeing the .NET logo or purple/related colors
        Assert.True(
            response.Text.Contains("logo", StringComparison.OrdinalIgnoreCase) ||
            response.Text.Contains("purple", StringComparison.OrdinalIgnoreCase) ||
            response.Text.Contains("dot", StringComparison.OrdinalIgnoreCase) ||
            response.Text.Contains("net", StringComparison.OrdinalIgnoreCase),
            $"Expected response to mention logo or colors, but got: {response.Text}");
    }
 
    [Fact]
    public async Task ToolCallResult_PdfDataContent()
    {
        SkipIfNotEnabled();
 
        var chatOptions = new ChatOptions
        {
            Tools = [AIFunctionFactory.Create(() => new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf") { Name = "document.pdf" }, "GetDocument", "Returns a PDF document")]
        };
 
        using var client = new FunctionInvokingChatClient(ChatClient);
 
        var response = await client.GetResponseAsync("Call GetDocument and tell me what text is in the PDF.", chatOptions);
 
        Assert.NotNull(response);
 
        // The PDF contains "Hello World!" text
        Assert.Contains("Hello World", response.Text, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task ToolCallResult_MixedContentWithImage()
    {
        SkipIfNotEnabled();
 
        var imageUri = ImageDataUri.GetImageDataUri();
        var imageBytes = Convert.FromBase64String(imageUri.ToString().Split(',')[1]);
 
        var chatOptions = new ChatOptions
        {
            Tools = [AIFunctionFactory.Create(() => new List<AIContent>
            {
                new TextContent("Analysis result: "),
                new DataContent(imageBytes, "image/png"),
                new TextContent(" - Image provided above")
            }, "AnalyzeImage", "Analyzes an image and returns results")]
        };
 
        using var client = new FunctionInvokingChatClient(ChatClient);
 
        var response = await client.GetResponseAsync("Call AnalyzeImage and describe what you see.", chatOptions);
 
        Assert.NotNull(response);
 
        // Should mention the analysis and describe the image
        Assert.True(
            response.Text.Contains("analysis", StringComparison.OrdinalIgnoreCase) ||
            response.Text.Contains("image", StringComparison.OrdinalIgnoreCase) ||
            response.Text.Contains("logo", StringComparison.OrdinalIgnoreCase),
            $"Expected response to mention analysis or image content, but got: {response.Text}");
    }
 
    [Fact]
    public async Task ReasoningContent_NonStreaming_RoundtripsEncryptedContent()
    {
        SkipIfNotEnabled();
 
        ChatOptions chatOptions = new()
        {
            ModelId = ReasoningModel,
            Reasoning = new()
            {
                Effort = ReasoningEffort.Low,
                Output = ReasoningOutput.Full,
            },
            RawRepresentationFactory = _ => new CreateResponseOptions
            {
                StoredOutputEnabled = false,
                IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent },
            },
        };
 
        // 1. First request: Get initial response with encrypted content
        List<ChatMessage> chatHistory = [new ChatMessage(ChatRole.User, "What is 2+2? Think step by step but be very brief.")];
 
        var response1 = await ChatClient.GetResponseAsync(chatHistory, chatOptions);
        Assert.NotNull(response1);
 
        // Verify we got reasoning content with encrypted data and RawRepresentation
        var reasoningContent = response1.Messages
            .SelectMany(m => m.Contents)
            .OfType<TextReasoningContent>()
            .FirstOrDefault();
 
        Assert.NotNull(reasoningContent);
        Assert.NotNull(reasoningContent.ProtectedData);
        Assert.NotEmpty(reasoningContent.ProtectedData);
        Assert.NotNull(reasoningContent.RawRepresentation);
 
        // 2. Second request: Uses raw representation
        chatHistory.AddMessages(response1);
        chatHistory.Add(new ChatMessage(ChatRole.User, "What is 3+3?"));
 
        var response2 = await ChatClient.GetResponseAsync(chatHistory, chatOptions);
        Assert.NotNull(response2);
        Assert.True(response2.Text.Contains("6") || response2.Text.Contains("six"));
 
        // 3. Serialize/deserialize to drop RawRepresentations, then make third request
        string json = JsonSerializer.Serialize(chatHistory, AIJsonUtilities.DefaultOptions);
        var deserializedHistory = JsonSerializer.Deserialize<List<ChatMessage>>(json, AIJsonUtilities.DefaultOptions)!;
 
        // Verify RawRepresentation was dropped but ProtectedData preserved
        var deserializedReasoning = deserializedHistory
            .SelectMany(m => m.Contents)
            .OfType<TextReasoningContent>()
            .First(r => !string.IsNullOrEmpty(r.ProtectedData));
 
        Assert.Null(deserializedReasoning.RawRepresentation);
        Assert.Equal(reasoningContent.ProtectedData, deserializedReasoning.ProtectedData);
 
        deserializedHistory.Add(new ChatMessage(ChatRole.User, "What is 4+4?"));
 
        var response3 = await ChatClient.GetResponseAsync(deserializedHistory, chatOptions);
        Assert.NotNull(response3);
        Assert.Contains("8", response3.Text);
 
        // 4. Corrupt the encrypted content and verify fourth request fails
        foreach (var reasoning in deserializedHistory
            .SelectMany(m => m.Contents)
            .OfType<TextReasoningContent>()
            .Where(r => !string.IsNullOrEmpty(r.ProtectedData)))
        {
            reasoning.ProtectedData = "completely_invalid_encrypted_content_that_should_fail";
        }
 
        deserializedHistory.Add(new ChatMessage(ChatRole.User, "What is 5+5?"));
 
        var ex = await Assert.ThrowsAsync<ClientResultException>(
            () => ChatClient.GetResponseAsync(deserializedHistory, chatOptions));
        Assert.Contains("encrypted", ex.Message, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task ReasoningContent_Streaming_RoundtripsEncryptedContent()
    {
        // This test requires a reasoning model with encrypted content support.
        SkipIfNotEnabled();
 
        ChatOptions chatOptions = new()
        {
            ModelId = ReasoningModel,
            Reasoning = new()
            {
                Effort = ReasoningEffort.Low,
                Output = ReasoningOutput.Full,
            },
            RawRepresentationFactory = _ => new CreateResponseOptions
            {
                StoredOutputEnabled = false,
                IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent },
            },
        };
 
        // 1. First request: Get initial response with encrypted content via streaming
        List<ChatMessage> chatHistory = [new ChatMessage(ChatRole.User, "What is 2+2? Think step by step but be very brief.")];
 
        var response1 = await ChatClient.GetStreamingResponseAsync(chatHistory, chatOptions).ToChatResponseAsync();
        Assert.NotNull(response1);
 
        // Verify we got reasoning content with encrypted data
        // Note: After coalescing streaming updates, RawRepresentation is not preserved
        var reasoningContent = response1.Messages
            .SelectMany(m => m.Contents)
            .OfType<TextReasoningContent>()
            .FirstOrDefault();
 
        Assert.NotNull(reasoningContent);
        Assert.NotNull(reasoningContent.ProtectedData);
        Assert.NotEmpty(reasoningContent.ProtectedData);
 
        // 2. Second request: Uses the coalesced content (no RawRepresentation after coalescing)
        chatHistory.AddMessages(response1);
        chatHistory.Add(new ChatMessage(ChatRole.User, "What is 3+3?"));
 
        var response2 = await ChatClient.GetStreamingResponseAsync(chatHistory, chatOptions).ToChatResponseAsync();
        Assert.NotNull(response2);
        Assert.Contains("6", response2.Text);
 
        // 3. Serialize/deserialize to ensure ProtectedData survives, then make third request
        string json = JsonSerializer.Serialize(chatHistory, AIJsonUtilities.DefaultOptions);
        var deserializedHistory = JsonSerializer.Deserialize<List<ChatMessage>>(json, AIJsonUtilities.DefaultOptions)!;
 
        // Verify ProtectedData preserved after serialization
        var deserializedReasoning = deserializedHistory
            .SelectMany(m => m.Contents)
            .OfType<TextReasoningContent>()
            .First(r => !string.IsNullOrEmpty(r.ProtectedData));
 
        Assert.Null(deserializedReasoning.RawRepresentation);
        Assert.Equal(reasoningContent.ProtectedData, deserializedReasoning.ProtectedData);
 
        deserializedHistory.Add(new ChatMessage(ChatRole.User, "What is 4+4?"));
 
        var response3 = await ChatClient.GetStreamingResponseAsync(deserializedHistory, chatOptions).ToChatResponseAsync();
        Assert.NotNull(response3);
        Assert.Contains("8", response3.Text);
 
        // 4. Corrupt the encrypted content and verify fourth request fails
        foreach (var reasoning in deserializedHistory
            .SelectMany(m => m.Contents)
            .OfType<TextReasoningContent>()
            .Where(r => !string.IsNullOrEmpty(r.ProtectedData)))
        {
            reasoning.ProtectedData = "completely_invalid_encrypted_content_that_should_fail";
        }
 
        deserializedHistory.Add(new ChatMessage(ChatRole.User, "What is 5+5?"));
 
        var ex = await Assert.ThrowsAsync<ClientResultException>(async () =>
        {
            await foreach (var update in ChatClient.GetStreamingResponseAsync(deserializedHistory, chatOptions))
            {
                _ = update;
            }
        });
        Assert.Contains("encrypted", ex.Message, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task UseToolSearch_WithDeferredFunctions()
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Tool search requires gpt-5.4 or later.");
 
        AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather.");
        AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time.");
 
        using var client = new FunctionInvokingChatClient(ChatClient);
        var response = await client.GetResponseAsync(
            "What's the weather like? Just respond with the weather info, nothing else.",
            new()
            {
                Tools =
                [
                    new HostedToolSearchTool(),
                    getWeather,
                    getTime,
                ],
            });
 
        Assert.NotNull(response);
        Assert.NotEmpty(response.Text);
 
        // Verify tool_search response items occurred.
        var rawJsons = response.Messages
            .SelectMany(m => m.Contents)
            .Where(c => c.RawRepresentation is ResponseItem)
            .Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString())
            .ToList();
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\""));
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\""));
    }
 
    [Fact]
    public async Task UseToolSearch_OnlyToolSearchNoFunctions_Throws()
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Tool search requires gpt-5.4 or later.");
 
        // HostedToolSearchTool with no deferred tools — the API rejects this with 400
        // because tool_search requires at least one tool with defer_loading.
        await Assert.ThrowsAsync<ClientResultException>(() =>
            ChatClient.GetResponseAsync(
                "Say hello.",
                new()
                {
                    Tools = [new HostedToolSearchTool()],
                }));
    }
 
    [Fact]
    public async Task UseToolSearch_WithNonDeferredFunctionsOnly_Throws()
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Tool search requires gpt-5.4 or later.");
 
        // HostedToolSearchTool with DeferredTools explicitly set to empty — no tools are deferred.
        // The API rejects this with 400 because tool_search requires at least one deferred tool.
        AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather.");
 
        await Assert.ThrowsAsync<ClientResultException>(() =>
            ChatClient.GetResponseAsync(
                "What's the weather? Reply with just the weather info.",
                new()
                {
                    Tools =
                    [
                        new HostedToolSearchTool { DeferredTools = [] },
                        getWeather,
                    ],
                }));
    }
 
    [Fact]
    public async Task UseToolSearch_DeferLoadingOnNonDeferrableTool_Throws()
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Tool search requires gpt-5.4 or later.");
 
        // Force defer_loading on a code_interpreter tool via Patch — the API should reject this.
        var codeTool = new HostedCodeInterpreterTool();
        var responseTool = codeTool.AsOpenAIResponseTool()!;
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
        responseTool.Patch.Set("$.defer_loading"u8, "true"u8);
#pragma warning restore SCME0001
 
        await Assert.ThrowsAsync<ClientResultException>(() =>
            ChatClient.GetResponseAsync(
                "Use code interpreter to calculate 2+2.",
                new()
                {
                    Tools =
                    [
                        responseTool.AsAITool(),
                    ],
                }));
    }
 
    [Fact]
    public async Task UseToolSearch_NamespaceWithDescription_RoundTrips()
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Tool search requires gpt-5.4 or later.");
 
        AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather.");
        AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time.");
 
        using var client = new FunctionInvokingChatClient(ChatClient);
        var response = await client.GetResponseAsync(
            "What's the weather like? Just respond with the weather info, nothing else.",
            new()
            {
                Tools =
                [
                    new HostedToolSearchTool
                    {
                        Namespace = "weather_and_time",
                        NamespaceDescription = "Tools for getting current weather and time.",
                        DeferredTools = ["GetWeather", "GetTime"],
                    },
                    getWeather,
                    getTime,
                ],
            });
 
        Assert.NotNull(response);
        Assert.NotEmpty(response.Text);
 
        // Verify tool_search response items occurred (the namespace wrapper must have been
        // accepted by the service for tool search to fire).
        var rawJsons = response.Messages
            .SelectMany(m => m.Contents)
            .Where(c => c.RawRepresentation is ResponseItem)
            .Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString())
            .ToList();
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\""));
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\""));
    }
 
    [Fact]
    public async Task UseToolSearch_TwoNamespacesWithDescriptions_RoundTrips()
    {
        SkipIfNotEnabled();
 
        Assert.SkipUnless(TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is true, "Tool search requires gpt-5.4 or later.");
 
        AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather.");
        AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time.");
        AIFunction getCustomer = AIFunctionFactory.Create((string id) => $"Customer {id}", "GetCustomer", "Gets a customer by id.");
 
        using var client = new FunctionInvokingChatClient(ChatClient);
        var response = await client.GetResponseAsync(
            "What's the weather like? Just respond with the weather info, nothing else.",
            new()
            {
                Tools =
                [
                    new HostedToolSearchTool
                    {
                        Namespace = "weather_and_time",
                        NamespaceDescription = "Tools for getting current weather and time.",
                        DeferredTools = ["GetWeather", "GetTime"],
                    },
                    new HostedToolSearchTool
                    {
                        Namespace = "crm",
                        NamespaceDescription = "Customer relationship management tools.",
                        DeferredTools = ["GetCustomer"],
                    },
                    getWeather,
                    getTime,
                    getCustomer,
                ],
            });
 
        Assert.NotNull(response);
        Assert.NotEmpty(response.Text);
 
        // Verify tool_search response items occurred (both namespace wrappers must have been
        // accepted by the service for tool search to fire).
        var rawJsons = response.Messages
            .SelectMany(m => m.Contents)
            .Where(c => c.RawRepresentation is ResponseItem)
            .Select(c => ModelReaderWriter.Write((ResponseItem)c.RawRepresentation!, ModelReaderWriterOptions.Json).ToString())
            .ToList();
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_call\"") || json.Contains("\"type\": \"tool_search_call\""));
        Assert.Contains(rawJsons, json => json.Contains("\"type\":\"tool_search_output\"") || json.Contains("\"type\": \"tool_search_output\""));
    }
}