File: OpenAIHostedFileClientIntegrationTests.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.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Xunit;
 
#pragma warning disable MEAI001
#pragma warning disable OPENAI001
 
namespace Microsoft.Extensions.AI;
 
public sealed class OpenAIHostedFileClientIntegrationTests : IDisposable
{
    private readonly IHostedFileClient? _client = IntegrationTestHelpers.GetOpenAIClient()?.AsIHostedFileClient();
 
    public void Dispose()
    {
        _client?.Dispose();
    }
 
    [Fact]
    public async Task Upload_Download_Delete_Roundtrip()
    {
        SkipIfNotEnabled();
 
        string fileName = $"test-{Guid.NewGuid():N}.jsonl";
        string content = """{"prompt": "hello", "completion": "world"}""";
        byte[] contentBytes = Encoding.UTF8.GetBytes(content);
        string? uploadedFileId = null;
 
        try
        {
            // Upload with "fine-tune" purpose since "assistants" files cannot be downloaded via the Files API.
            using var uploadStream = new MemoryStream(contentBytes);
            var uploadedFile = await _client!.UploadAsync(uploadStream, "application/jsonl", fileName, new HostedFileClientOptions { Purpose = "fine-tune" });
            uploadedFileId = uploadedFile.FileId;
 
            Assert.NotNull(uploadedFile.FileId);
            Assert.NotEmpty(uploadedFile.FileId);
            Assert.Equal(fileName, uploadedFile.Name);
            Assert.Equal("application/x-ndjson", uploadedFile.MediaType);
            Assert.NotNull(uploadedFile.Purpose);
            Assert.NotNull(uploadedFile.SizeInBytes);
            Assert.Equal(contentBytes.Length, uploadedFile.SizeInBytes);
            Assert.NotNull(uploadedFile.CreatedAt);
            Assert.NotNull(uploadedFile.RawRepresentation);
 
            // Download (with retry - files may not be immediately available)
            string downloadedContent = await RetryAsync(async () =>
            {
                using var downloadStream = await _client.DownloadAsync(uploadedFileId);
                Assert.Equal("application/x-ndjson", downloadStream.MediaType);
                Assert.Equal(fileName, downloadStream.FileName);
                using var reader = new StreamReader(downloadStream, Encoding.UTF8);
                return await reader.ReadToEndAsync();
            });
 
            Assert.Equal(content, downloadedContent);
 
            // Delete
            bool deleted = await _client.DeleteAsync(uploadedFileId);
            Assert.True(deleted);
            uploadedFileId = null;
 
            // Verify not found after deletion
            var fileInfo = await _client.GetFileInfoAsync(uploadedFile.FileId);
            Assert.Null(fileInfo);
        }
        finally
        {
            if (uploadedFileId is not null)
            {
                await _client!.DeleteAsync(uploadedFileId);
            }
        }
    }
 
    [Fact]
    public async Task Upload_ListFiles_VerifyPresent()
    {
        SkipIfNotEnabled();
 
        string fileName = $"test-{Guid.NewGuid():N}.txt";
        string? uploadedFileId = null;
 
        try
        {
            using var uploadStream = new MemoryStream("list test"u8.ToArray());
            var uploadedFile = await _client!.UploadAsync(uploadStream, "text/plain", fileName, new HostedFileClientOptions { Purpose = "assistants" });
            uploadedFileId = uploadedFile.FileId;
 
            var files = new List<HostedFileContent>();
            await foreach (var file in _client.ListFilesAsync(new HostedFileClientOptions { Purpose = "assistants" }))
            {
                files.Add(file);
            }
 
            Assert.Contains(files, f => f.FileId == uploadedFileId && f.MediaType == "text/plain" && f.Name == fileName);
        }
        finally
        {
            if (uploadedFileId is not null)
            {
                await _client!.DeleteAsync(uploadedFileId);
            }
        }
    }
 
    [Fact]
    public async Task GetFileInfo_ReturnsMetadata()
    {
        SkipIfNotEnabled();
 
        string fileName = $"test-{Guid.NewGuid():N}.txt";
        byte[] contentBytes = "metadata test"u8.ToArray();
        string? uploadedFileId = null;
 
        try
        {
            using var uploadStream = new MemoryStream(contentBytes);
            var uploadedFile = await _client!.UploadAsync(uploadStream, "text/plain", fileName, new HostedFileClientOptions { Purpose = "assistants" });
            uploadedFileId = uploadedFile.FileId;
 
            var fileInfo = await _client.GetFileInfoAsync(uploadedFileId);
 
            Assert.NotNull(fileInfo);
            Assert.Equal(uploadedFileId, fileInfo.FileId);
            Assert.Equal(fileName, fileInfo.Name);
            Assert.Equal("text/plain", fileInfo.MediaType);
            Assert.NotNull(fileInfo.SizeInBytes);
            Assert.True(fileInfo.SizeInBytes > 0);
            Assert.NotNull(fileInfo.Purpose);
            Assert.NotNull(fileInfo.CreatedAt);
        }
        finally
        {
            if (uploadedFileId is not null)
            {
                await _client!.DeleteAsync(uploadedFileId);
            }
        }
    }
 
    [Fact]
    public async Task Delete_NonExistent_ReturnsFalse()
    {
        SkipIfNotEnabled();
 
        bool deleted = await _client!.DeleteAsync("file-nonexistent000000000000");
        Assert.False(deleted);
    }
 
    [Fact]
    public async Task GetFileInfo_NonExistent_ReturnsNull()
    {
        SkipIfNotEnabled();
 
        var fileInfo = await _client!.GetFileInfoAsync("file-nonexistent000000000000");
        Assert.Null(fileInfo);
    }
 
    [Fact]
    public async Task Upload_DataContent_Extension()
    {
        SkipIfNotEnabled();
 
        string fileName = $"test-{Guid.NewGuid():N}.txt";
        byte[] contentBytes = "data content test"u8.ToArray();
        string? uploadedFileId = null;
 
        try
        {
            var dataContent = new DataContent(contentBytes, "text/plain") { Name = fileName };
            var uploadedFile = await _client!.UploadAsync(dataContent, new HostedFileClientOptions { Purpose = "assistants" });
            uploadedFileId = uploadedFile.FileId;
 
            Assert.NotNull(uploadedFile.FileId);
            Assert.NotEmpty(uploadedFile.FileId);
            Assert.Equal("text/plain", uploadedFile.MediaType);
        }
        finally
        {
            if (uploadedFileId is not null)
            {
                await _client!.DeleteAsync(uploadedFileId);
            }
        }
    }
 
    [Fact]
    public async Task Download_AsDataContent_Extension()
    {
        SkipIfNotEnabled();
 
        string fileName = $"test-{Guid.NewGuid():N}.jsonl";
        string content = """{"prompt": "hello", "completion": "world"}""";
        byte[] contentBytes = Encoding.UTF8.GetBytes(content);
        string? uploadedFileId = null;
 
        try
        {
            using var uploadStream = new MemoryStream(contentBytes);
            var uploadedFile = await _client!.UploadAsync(uploadStream, "application/jsonl", fileName, new HostedFileClientOptions { Purpose = "fine-tune" });
            uploadedFileId = uploadedFile.FileId;
 
            var dataContent = await RetryAsync(() => _client.DownloadAsDataContentAsync(uploadedFileId));
            Assert.Equal(content, Encoding.UTF8.GetString(dataContent.Data.ToArray()));
            Assert.Equal("application/x-ndjson", dataContent.MediaType);
        }
        finally
        {
            if (uploadedFileId is not null)
            {
                await _client!.DeleteAsync(uploadedFileId);
            }
        }
    }
 
    [Fact]
    public async Task Upload_DownloadTo_Extension()
    {
        SkipIfNotEnabled();
 
        string fileName = $"test-{Guid.NewGuid():N}.jsonl";
        string content = """{"prompt": "hello", "completion": "world"}""";
        byte[] contentBytes = Encoding.UTF8.GetBytes(content);
        string? uploadedFileId = null;
        string? tempDir = null;
 
        try
        {
            using var uploadStream = new MemoryStream(contentBytes);
            var uploadedFile = await _client!.UploadAsync(uploadStream, "application/jsonl", fileName, new HostedFileClientOptions { Purpose = "fine-tune" });
            uploadedFileId = uploadedFile.FileId;
 
            tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(tempDir);
 
            string savedPath = await RetryAsync(() => _client.DownloadToAsync(uploadedFileId, tempDir));
 
            Assert.True(File.Exists(savedPath));
            string savedContent = File.ReadAllText(savedPath);
            Assert.Equal(content, savedContent);
        }
        finally
        {
            if (uploadedFileId is not null)
            {
                await _client!.DeleteAsync(uploadedFileId);
            }
 
            if (tempDir is not null && Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task Container_Upload_Download_Delete_Roundtrip()
    {
        SkipIfNotEnabled();
 
        // Create a container-scoped file client
        using var containerClient = IntegrationTestHelpers.GetOpenAIClient()!.GetContainerClient().AsIHostedFileClient();
 
        // First, use the chat client with code interpreter to get a container ID
        using var chatClient = IntegrationTestHelpers.GetOpenAIClient()!
            .GetResponsesClient()
            .AsIChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini");
 
        var response = await chatClient.GetResponseAsync(
            "Calculate 2+2 using Python",
            new ChatOptions { Tools = [new HostedCodeInterpreterTool()] });
 
        // Extract container ID from the code interpreter result
        string? containerId = null;
        foreach (var msg in response.Messages)
        {
            foreach (var content in msg.Contents)
            {
                if (content is CodeInterpreterToolResultContent { RawRepresentation: OpenAI.Responses.CodeInterpreterCallResponseItem cicri })
                {
                    containerId = cicri.ContainerId;
                }
            }
        }
 
        Assert.NotNull(containerId);
 
        // Upload a file to the container
        string content2 = """{"data": "test"}""";
        byte[] contentBytes = Encoding.UTF8.GetBytes(content2);
        using var uploadStream = new MemoryStream(contentBytes);
        var uploadedFile = await containerClient.UploadAsync(
            uploadStream, "application/json", "test-data.json",
            new HostedFileClientOptions { Scope = containerId });
 
        Assert.NotNull(uploadedFile.FileId);
        Assert.NotEmpty(uploadedFile.FileId);
        Assert.Equal(containerId, uploadedFile.Scope);
 
        // List files in the container
        var files = new List<HostedFileContent>();
        await foreach (var file in containerClient.ListFilesAsync(new HostedFileClientOptions { Scope = containerId }))
        {
            files.Add(file);
        }
 
        Assert.Contains(files, f => f.FileId == uploadedFile.FileId);
 
        // Get file info
        var fileInfo = await containerClient.GetFileInfoAsync(uploadedFile.FileId, new HostedFileClientOptions { Scope = containerId });
        Assert.NotNull(fileInfo);
        Assert.Equal(uploadedFile.FileId, fileInfo.FileId);
 
        // Download the file
        using var downloadStream = await containerClient.DownloadAsync(uploadedFile.FileId, new HostedFileClientOptions { Scope = containerId });
        Assert.NotNull(downloadStream);
        using var reader = new StreamReader(downloadStream, Encoding.UTF8);
        string downloadedContent = await reader.ReadToEndAsync();
        Assert.Equal(content2, downloadedContent);
 
        // Delete the file
        bool deleted = await containerClient.DeleteAsync(uploadedFile.FileId, new HostedFileClientOptions { Scope = containerId });
        Assert.True(deleted);
    }
 
    [Fact]
    public async Task CodeInterpreter_ProducesDownloadableOutputs()
    {
        SkipIfNotEnabled();
 
        using var chatClient = IntegrationTestHelpers.GetOpenAIClient()!
            .GetResponsesClient()
            .AsIChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini");
 
        using var fileClient = IntegrationTestHelpers.GetOpenAIClient()!.GetContainerClient().AsIHostedFileClient();
 
        // Ask the model to create a file via code interpreter
        var response = await chatClient.GetResponseAsync(
            "Use Python to create a JSON file at /mnt/data/output.json containing the numbers 1 through 5 as an array. "
            + "Do not include any text explanation, just run the code.",
            new ChatOptions { Tools = [new HostedCodeInterpreterTool()] });
 
        // Find the code interpreter result and its container
        string? containerId = null;
        foreach (var msg in response.Messages)
        {
            foreach (var content in msg.Contents)
            {
                if (content is CodeInterpreterToolResultContent { RawRepresentation: OpenAI.Responses.CodeInterpreterCallResponseItem cicri })
                {
                    containerId = cicri.ContainerId;
                }
            }
        }
 
        Assert.NotNull(containerId);
 
        // List files in the container — the code interpreter should have created at least the output file
        var containerFiles = new List<HostedFileContent>();
        await foreach (var file in fileClient.ListFilesAsync(new HostedFileClientOptions { Scope = containerId }))
        {
            containerFiles.Add(file);
        }
 
        Assert.NotEmpty(containerFiles);
 
        // Download a container file and verify it's non-empty
        var firstFile = containerFiles[0];
        using var downloadStream = await fileClient.DownloadAsync(firstFile, new HostedFileClientOptions { Scope = containerId });
        Assert.NotNull(downloadStream);
        using var ms = new MemoryStream();
        await downloadStream.CopyToAsync(ms);
        Assert.True(ms.Length > 0);
    }
 
    [Fact]
    public async Task CodeInterpreter_Upload_ProcessedByCodeInterpreter()
    {
        SkipIfNotEnabled();
 
        using var chatClient = IntegrationTestHelpers.GetOpenAIClient()!
            .GetResponsesClient()
            .AsIChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini");
 
        using var fileClient = IntegrationTestHelpers.GetOpenAIClient()!.GetContainerClient().AsIHostedFileClient();
 
        // First, use code interpreter to get a container ID
        var setupResponse = await chatClient.GetResponseAsync(
            "Calculate 1+1 using Python",
            new ChatOptions { Tools = [new HostedCodeInterpreterTool()] });
 
        string? containerId = null;
        foreach (var msg in setupResponse.Messages)
        {
            foreach (var content in msg.Contents)
            {
                if (content is CodeInterpreterToolResultContent { RawRepresentation: OpenAI.Responses.CodeInterpreterCallResponseItem cicri })
                {
                    containerId = cicri.ContainerId;
                }
            }
        }
 
        Assert.NotNull(containerId);
 
        // Upload a CSV file to the container
        string csvContent = "name,value\nalpha,1\nbeta,2\ngamma,3";
        using var uploadStream = new MemoryStream(Encoding.UTF8.GetBytes(csvContent));
        var uploadedFile = await fileClient.UploadAsync(
            uploadStream, "text/csv", "data.csv",
            new HostedFileClientOptions { Scope = containerId });
 
        Assert.NotNull(uploadedFile.FileId);
        Assert.Equal(containerId, uploadedFile.Scope);
 
        // Ask the model to process the uploaded file, continuing the same conversation
        var processResponse = await chatClient.GetResponseAsync(
            "Read the CSV file at /mnt/data/data.csv and tell me the sum of the 'value' column. Reply with just the number.",
            new ChatOptions
            {
                Tools = [new HostedCodeInterpreterTool()],
                ConversationId = setupResponse.ConversationId,
            });
 
        // The response should mention "6" (1+2+3)
        string responseText = processResponse.Text ?? "";
        Assert.Contains("6", responseText);
    }
 
    /// <summary>Retries a download operation to handle the delay between upload and file availability.</summary>
    private static async Task<T> RetryAsync<T>(Func<Task<T>> action, int maxRetries = 5, int delayMs = 2000)
    {
        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                return await action();
            }
            catch (ClientResultException) when (i < maxRetries - 1)
            {
                await Task.Delay(delayMs * (i + 1));
            }
        }
 
        return await action();
    }
 
    private void SkipIfNotEnabled()
    {
        string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"];
 
        Assert.SkipUnless(skipIntegration is null && _client is not null, "Client is not enabled.");
    }
}