From 018264d6d5583b89e8d435d61a2f43b172cc7a93 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:14:41 -0500 Subject: [PATCH 01/21] feat: initial implementation of creating a file via the Files API --- README.md | 66 +++++++++++++++++++ src/AnthropicClient/AnthropicApiClient.cs | 19 ++++++ src/AnthropicClient/IAnthropicApiClient.cs | 8 +++ src/AnthropicClient/Models/AnthropicFile.cs | 53 +++++++++++++++ .../Models/CreateFileRequest.cs | 66 +++++++++++++++++++ .../Unit/Models/CreateFileRequestTests.cs | 0 6 files changed, 212 insertions(+) create mode 100644 src/AnthropicClient/Models/AnthropicFile.cs create mode 100644 src/AnthropicClient/Models/CreateFileRequest.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CreateFileRequestTests.cs diff --git a/README.md b/README.md index 04a1744..246ebbb 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,72 @@ if (response.IsFailure) Console.WriteLine("Model Id: {0}", response.Value.Id); ``` +### Files API + +The `AnthropicApiClient` provides support for the Anthropic Files API, which allows you to upload and manage files for use with the Anthropic API. + +#### Create a File + +You can create a file using the Files API in several ways: + +##### From a Byte Array + +```csharp +using AnthropicClient; +using AnthropicClient.Models; +using System.Text; + +// Create a file from a byte array +var content = "This is a sample text file for the Anthropic Files API."; +var fileBytes = Encoding.UTF8.GetBytes(content); + +var request = new CreateFileRequest(fileBytes, "sample.txt", "text/plain"); +var response = await client.CreateFileAsync(request); + +if (response.IsSuccess) +{ + Console.WriteLine("File created successfully from byte array!"); + Console.WriteLine("File ID: {0}", response.Value.Id); + Console.WriteLine("File Name: {0}", response.Value.FileName); + Console.WriteLine("File Type: {0}", response.Value.FileType); + Console.WriteLine("File Size: {0} bytes", response.Value.SizeBytes); +} +else +{ + Console.WriteLine("Failed to create file"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); +} +``` + +##### From a Stream + +```csharp +using AnthropicClient; +using AnthropicClient.Models; +using System.Text; + +// Create a file from a stream +using var stream = new MemoryStream(Encoding.UTF8.GetBytes("Stream content")); +var request = new CreateFileRequest(stream, "stream-file.txt", "text/plain"); +var response = await client.CreateFileAsync(request); + +if (response.IsSuccess) +{ + Console.WriteLine("File created successfully from stream!"); + Console.WriteLine("File ID: {0}", response.Value.Id); +} +else +{ + Console.WriteLine("Failed to create file"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); +} +``` + +> [!NOTE] +> The Files API has certain limitations on file size, supported file types, and usage quotas. Please refer to the [Anthropic API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/files) for the most up-to-date information on these limitations. + ### Create a message The `AnthropicApiClient` exposes a method named `CreateMessageAsync` that can be used to create a message. The method requires a `MessageRequest` or a `StreamMessageRequest` instance as a parameter. The `MessageRequest` class is used to create a message whose response is not streamed and the `StreamMessageRequest` class is used to create a message whose response is streamed. The `MessageRequest` instance's properties can be set to configure how the message is created. diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 2e78ac2..cfcb63c 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -18,6 +18,7 @@ public class AnthropicApiClient : IAnthropicApiClient private string CountTokensEndpoint => $"{MessagesEndpoint}/count_tokens"; private string MessageBatchesEndpoint => $"{MessagesEndpoint}/batches"; private const string ModelsEndpoint = "models"; + private const string FilesEndpoint = "files"; private const string JsonContentType = "application/json"; private const string EventPrefix = "event:"; private const string DataPrefix = "data:"; @@ -380,6 +381,13 @@ public async Task> GetModelAsync(string modelId, return await CreateResultAsync(response); } + /// + public async Task> CreateFileAsync(CreateFileRequest request, CancellationToken cancellationToken = default) + { + var response = await SendFileRequestAsync(FilesEndpoint, request, cancellationToken); + return await CreateResultAsync(response); + } + private async IAsyncEnumerable>> GetAllPagesAsync(string endpoint, int limit = 20, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var pagingRequest = new PagingRequest(limit: limit); @@ -462,6 +470,17 @@ private async Task SendRequestAsync(string endpoint, T r return await _httpClient.PostAsync(endpoint, requestContent, cancellationToken); } + private async Task SendFileRequestAsync(string endpoint, CreateFileRequest request, CancellationToken cancellationToken = default) + { + using var multipartContent = new MultipartFormDataContent(); + + using var fileContent = new ByteArrayContent(request.File); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(request.FileType); + multipartContent.Add(fileContent, "file", request.FileName); + + return await _httpClient.PostAsync(endpoint, multipartContent, cancellationToken); + } + private string Serialize(T obj) => JsonSerializer.Serialize(obj, JsonSerializationOptions.DefaultOptions); private T? Deserialize(string json) => JsonSerializer.Deserialize(json, JsonSerializationOptions.DefaultOptions); } \ No newline at end of file diff --git a/src/AnthropicClient/IAnthropicApiClient.cs b/src/AnthropicClient/IAnthropicApiClient.cs index e4f63b9..0dee03f 100644 --- a/src/AnthropicClient/IAnthropicApiClient.cs +++ b/src/AnthropicClient/IAnthropicApiClient.cs @@ -111,4 +111,12 @@ public interface IAnthropicApiClient /// A token to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the response as an where T is . Task> GetModelAsync(string modelId, CancellationToken cancellationToken = default); + + /// + /// Creates a file asynchronously using the Files API. + /// + /// The file creation request. + /// A token to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> CreateFileAsync(CreateFileRequest request, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/AnthropicClient/Models/AnthropicFile.cs b/src/AnthropicClient/Models/AnthropicFile.cs new file mode 100644 index 0000000..0cc2ca6 --- /dev/null +++ b/src/AnthropicClient/Models/AnthropicFile.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a file object from the Anthropic Files API. +/// +public class AnthropicFile +{ + /// + /// Unique object identifier. + /// The format and length of IDs may change over time. + /// + [JsonPropertyName("id")] + public string Id { get; init; } = string.Empty; + + /// + /// Object type. + /// For files, this is always "file". + /// + [JsonPropertyName("type")] + public string Type { get; init; } = "file"; + + /// + /// Original filename of the uploaded file. + /// + [JsonPropertyName("file_name")] + public string FileName { get; init; } = string.Empty; + + /// + /// RFC 3339 datetime string representing when the file was created. + /// + [JsonPropertyName("created_at")] + public string CreatedAt { get; init; } = string.Empty; + + /// + /// Size of the file in bytes. + /// + [JsonPropertyName("size_bytes")] + public long SizeBytes { get; init; } + + /// + /// MIME type of the file. + /// + [JsonPropertyName("file_type")] + public string FileType { get; init; } = string.Empty; + + /// + /// Whether the file can be downloaded. + /// + [JsonPropertyName("downloadable")] + public bool Downloadable { get; init; } +} diff --git a/src/AnthropicClient/Models/CreateFileRequest.cs b/src/AnthropicClient/Models/CreateFileRequest.cs new file mode 100644 index 0000000..33bae0c --- /dev/null +++ b/src/AnthropicClient/Models/CreateFileRequest.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a request to create a file via the Anthropic Files API. +/// +public class CreateFileRequest +{ + /// + /// The file content as a byte array. + /// + public byte[] File { get; } + + /// + /// The original filename of the file being uploaded. + /// + public string FileName { get; } + + /// + /// The MIME type of the file. + /// + public string FileType { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The file content as a byte array. + /// The original filename of the file being uploaded. + /// The MIME type of the file. + /// Thrown when , , or is null. + public CreateFileRequest(byte[] file, string fileName, string fileType) + { + ArgumentValidator.ThrowIfNull(file, nameof(file)); + ArgumentValidator.ThrowIfNullOrWhitespace(fileName, nameof(fileName)); + ArgumentValidator.ThrowIfNullOrWhitespace(fileType, nameof(fileType)); + + File = file; + FileName = fileName; + FileType = fileType; + } + + /// + /// Initializes a new instance of the class from a stream. + /// + /// The stream containing the file content. + /// The original filename of the file being uploaded. + /// The MIME type of the file. + /// Thrown when , , or is null. + public CreateFileRequest(Stream stream, string fileName, string fileType) + { + ArgumentValidator.ThrowIfNull(stream, nameof(stream)); + ArgumentValidator.ThrowIfNullOrWhitespace(fileName, nameof(fileName)); + ArgumentValidator.ThrowIfNullOrWhitespace(fileType, nameof(fileType)); + + using var memoryStream = new MemoryStream(); + stream.CopyToAsync(memoryStream); + var fileContent = memoryStream.ToArray(); + + File = fileContent; + FileName = fileName; + FileType = fileType; + } +} diff --git a/tests/AnthropicClient.Tests/Unit/Models/CreateFileRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CreateFileRequestTests.cs new file mode 100644 index 0000000..e69de29 From ab6d45522266de1c7ee4ad8d42c28d36241fc5d9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 12 Jul 2025 20:40:01 -0500 Subject: [PATCH 02/21] fix: remove unnecessary usings --- src/AnthropicClient/Models/CreateFileRequest.cs | 2 -- src/AnthropicClient/Models/DocumentSource.cs | 2 -- src/AnthropicClient/Models/ImageSource.cs | 2 -- 3 files changed, 6 deletions(-) diff --git a/src/AnthropicClient/Models/CreateFileRequest.cs b/src/AnthropicClient/Models/CreateFileRequest.cs index 33bae0c..1d0fcf9 100644 --- a/src/AnthropicClient/Models/CreateFileRequest.cs +++ b/src/AnthropicClient/Models/CreateFileRequest.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - using AnthropicClient.Utils; namespace AnthropicClient.Models; diff --git a/src/AnthropicClient/Models/DocumentSource.cs b/src/AnthropicClient/Models/DocumentSource.cs index e7029f2..9bc2505 100644 --- a/src/AnthropicClient/Models/DocumentSource.cs +++ b/src/AnthropicClient/Models/DocumentSource.cs @@ -1,7 +1,5 @@ using System.Text.Json.Serialization; -using AnthropicClient.Utils; - namespace AnthropicClient.Models; /// diff --git a/src/AnthropicClient/Models/ImageSource.cs b/src/AnthropicClient/Models/ImageSource.cs index 35257c5..e97604a 100644 --- a/src/AnthropicClient/Models/ImageSource.cs +++ b/src/AnthropicClient/Models/ImageSource.cs @@ -1,7 +1,5 @@ using System.Text.Json.Serialization; -using AnthropicClient.Utils; - namespace AnthropicClient.Models; /// From 31539a8c876c7c78c1919c6cc3ce3bea27ec23b6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 12 Jul 2025 20:49:28 -0500 Subject: [PATCH 03/21] tests: add create file request tests --- .../Unit/Models/CreateFileRequestTests.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/AnthropicClient.Tests/Unit/Models/CreateFileRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CreateFileRequestTests.cs index e69de29..0b749dd 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/CreateFileRequestTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/CreateFileRequestTests.cs @@ -0,0 +1,106 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CreateFileRequestTests +{ + [Fact] + public void Constructor_WhenCalledWithBytes_ItShouldInitializeProperties() + { + var fileContent = new byte[] { 1, 2, 3 }; + var fileName = "test.txt"; + var fileType = "text/plain"; + + var request = new CreateFileRequest(fileContent, fileName, fileType); + + request.File.Should().BeSameAs(fileContent); + request.FileName.Should().Be(fileName); + request.FileType.Should().Be(fileType); + } + + [Fact] + public void Constructor_WhenCalledWithStream_ItShouldInitializeProperties() + { + using var stream = new MemoryStream([1, 2, 3]); + var fileName = "test.txt"; + var fileType = "text/plain"; + + var request = new CreateFileRequest(stream, fileName, fileType); + + request.File.Should().BeEquivalentTo(new byte[] { 1, 2, 3 }); + request.FileName.Should().Be(fileName); + request.FileType.Should().Be(fileType); + } + + [Fact] + public void Constructor_WhenCalledWithNullBytes_ItShouldThrowArgumentNullException() + { + var act = () => new CreateFileRequest((byte[])null!, "test.txt", "text/plain"); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithBytesAndNullFileName_ItShouldThrowArgumentException() + { + var act = () => new CreateFileRequest([1, 2, 3], null!, "text/plain"); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithBytesAndFileNameIsEmpty_ItShouldThrowArgumentException() + { + var act = () => new CreateFileRequest([1, 2, 3], string.Empty, "text/plain"); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithBytesAndNullFileType_ItShouldThrowArgumentException() + { + var act = () => new CreateFileRequest([1, 2, 3], "test.txt", null!); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithBytesAndFileTypeIsEmpty_ItShouldThrowArgumentException() + { + var act = () => new CreateFileRequest([1, 2, 3], "test.txt", string.Empty); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithNullStream_ItShouldThrowArgumentNullException() + { + var act = () => new CreateFileRequest((Stream)null!, "test.txt", "text/plain"); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithStreamAndNullFileName_ItShouldThrowArgumentException() + { + using var stream = new MemoryStream([1, 2, 3]); + var act = () => new CreateFileRequest(stream, null!, "text/plain"); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithStreamAndFileNameIsEmpty_ItShouldThrowArgumentException() + { + using var stream = new MemoryStream([1, 2, 3]); + var act = () => new CreateFileRequest(stream, string.Empty, "text/plain"); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithStreamAndNullFileType_ItShouldThrowArgumentException() + { + using var stream = new MemoryStream([1, 2, 3]); + var act = () => new CreateFileRequest(stream, "test.txt", null!); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithStreamAndFileTypeIsEmpty_ItShouldThrowArgumentException() + { + using var stream = new MemoryStream([1, 2, 3]); + var act = () => new CreateFileRequest(stream, "test.txt", string.Empty); + act.Should().Throw(); + } +} \ No newline at end of file From 94c30282731111c28fa56ab22c14cf32eda9f785 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 12 Jul 2025 20:49:36 -0500 Subject: [PATCH 04/21] docs: fix xml comments --- src/AnthropicClient/Models/CreateFileRequest.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/AnthropicClient/Models/CreateFileRequest.cs b/src/AnthropicClient/Models/CreateFileRequest.cs index 1d0fcf9..535c9a3 100644 --- a/src/AnthropicClient/Models/CreateFileRequest.cs +++ b/src/AnthropicClient/Models/CreateFileRequest.cs @@ -10,17 +10,17 @@ public class CreateFileRequest /// /// The file content as a byte array. /// - public byte[] File { get; } + public byte[] File { get; init; } /// /// The original filename of the file being uploaded. /// - public string FileName { get; } + public string FileName { get; init; } /// /// The MIME type of the file. /// - public string FileType { get; } + public string FileType { get; init; } /// /// Initializes a new instance of the class. @@ -28,7 +28,8 @@ public class CreateFileRequest /// The file content as a byte array. /// The original filename of the file being uploaded. /// The MIME type of the file. - /// Thrown when , , or is null. + /// Thrown when is null. + /// Thrown when or is null or whitespace. public CreateFileRequest(byte[] file, string fileName, string fileType) { ArgumentValidator.ThrowIfNull(file, nameof(file)); @@ -46,7 +47,8 @@ public CreateFileRequest(byte[] file, string fileName, string fileType) /// The stream containing the file content. /// The original filename of the file being uploaded. /// The MIME type of the file. - /// Thrown when , , or is null. + /// Thrown when is null. + /// Thrown when or is null or whitespace. public CreateFileRequest(Stream stream, string fileName, string fileType) { ArgumentValidator.ThrowIfNull(stream, nameof(stream)); From a5746a08525edd88c888eff768a7d0291bdc0a43 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 12 Jul 2025 21:49:49 -0500 Subject: [PATCH 05/21] tests: add integration test for creating file --- src/AnthropicClient/Models/AnthropicFile.cs | 16 ++++---- .../Integration/AnthropicApiClientTests.cs | 37 +++++++++++++++++++ .../Integration/IntegrationTest.cs | 7 ++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/AnthropicClient/Models/AnthropicFile.cs b/src/AnthropicClient/Models/AnthropicFile.cs index 0cc2ca6..a736a39 100644 --- a/src/AnthropicClient/Models/AnthropicFile.cs +++ b/src/AnthropicClient/Models/AnthropicFile.cs @@ -9,14 +9,12 @@ public class AnthropicFile { /// /// Unique object identifier. - /// The format and length of IDs may change over time. /// [JsonPropertyName("id")] public string Id { get; init; } = string.Empty; /// /// Object type. - /// For files, this is always "file". /// [JsonPropertyName("type")] public string Type { get; init; } = "file"; @@ -24,26 +22,26 @@ public class AnthropicFile /// /// Original filename of the uploaded file. /// - [JsonPropertyName("file_name")] - public string FileName { get; init; } = string.Empty; + [JsonPropertyName("filename")] + public string Name { get; init; } = string.Empty; /// - /// RFC 3339 datetime string representing when the file was created. + /// Date file was created. /// [JsonPropertyName("created_at")] - public string CreatedAt { get; init; } = string.Empty; + public DateTimeOffset CreatedAt { get; init; } /// /// Size of the file in bytes. /// [JsonPropertyName("size_bytes")] - public long SizeBytes { get; init; } + public long Size { get; init; } /// /// MIME type of the file. /// - [JsonPropertyName("file_type")] - public string FileType { get; init; } = string.Empty; + [JsonPropertyName("mime_type")] + public string MimeType { get; init; } = string.Empty; /// /// Whether the file can be downloaded. diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 6cab022..ff05315 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1928,4 +1928,41 @@ public async Task DeleteMessageBatchAsync_WhenCalled_ItShouldReturnDeletionRespo result.Value.Id.Should().Be(batchId); result.Value.Type.Should().Be("message_batch_deleted"); } + + [Fact] + public async Task CreateFileAsync_WhenCalled_ItShouldReturnFile() + { + _mockHttpMessageHandler + .WhenCreateFileRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""created_at"": ""2023-11-07T05:31:56Z"", + ""downloadable"": false, + ""filename"": ""example.txt"", + ""id"": ""file_013Zva2CMHLNnXjNJJKqJ2EF"", + ""mime_type"": ""text/plain"", + ""size_bytes"": 1234, + ""type"": ""file"" + }" + ); + + var fileContent = new MemoryStream(Encoding.UTF8.GetBytes("Example file content")); + var request = new CreateFileRequest(fileContent, "example.txt", "text/plain"); + + var result = await Client.CreateFileAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEquivalentTo(new AnthropicFile() + { + CreatedAt = DateTimeOffset.Parse("2023-11-07T05:31:56Z"), + Downloadable = false, + Name = "example.txt", + Id = "file_013Zva2CMHLNnXjNJJKqJ2EF", + MimeType = "text/plain", + Size = 1234, + Type = "file" + }); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index 223107b..f618b44 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -20,6 +20,7 @@ public static class MockHttpMessageHandlerExtensions private static readonly string CountTokensEndpoint = $"{BaseUrl}/messages/count_tokens"; private static readonly string MessageBatchesEndpoint = $"{BaseUrl}/messages/batches"; private static readonly string ModelsEndpoint = $"{BaseUrl}/models"; + private static readonly string FilesEndpoint = $"{BaseUrl}/files"; private static MockedRequest SetupBaseRequest( this MockHttpMessageHandler mockHttpMessageHandler, @@ -103,4 +104,10 @@ public static MockedRequest WhenDeleteMessageBatchRequest(this MockHttpMessageHa return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Delete, $"{MessageBatchesEndpoint}/{batchId}"); } + + public static MockedRequest WhenCreateFileRequest(this MockHttpMessageHandler mockHttpMessageHandler) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Post, FilesEndpoint); + } } \ No newline at end of file From 98236bb8eebbb85e4589ad51fe56ed8240b9a191 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:13:10 -0500 Subject: [PATCH 06/21] tests: add end to end test --- .../EndToEnd/AnthropicApiClientTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index cb1b9fe..1b85111 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -687,4 +687,25 @@ public async Task CancelMessageBatchAsync_WhenCalled_ItShouldReturnResponse() result.Value.Id.Should().Be(createResult.Value.Id); result.Value.ProcessingStatus.Should().Be(MessageBatchStatus.Canceling); } + + [Fact] + public async Task CreateFileAsync_WhenCalled_ItShouldReturnResponse() + { + var fileName = "story.txt"; + var fileType = "text/plain"; + var filePath = TestFileHelper.GetTestFilePath("story.txt"); + var fileContent = await File.ReadAllBytesAsync(filePath); + var request = new CreateFileRequest(fileContent, fileName, fileType); + + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + var result = await client.CreateFileAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Name.Should().Be(fileName); + result.Value.MimeType.Should().Be(fileType); + } } \ No newline at end of file From a5eaa4b99dd74a63a5038e1c9fcb05af1772890f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:16:07 -0500 Subject: [PATCH 07/21] docs: add note about files beta status --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 246ebbb..dcc1786 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,9 @@ Console.WriteLine("Model Id: {0}", response.Value.Id); The `AnthropicApiClient` provides support for the Anthropic Files API, which allows you to upload and manage files for use with the Anthropic API. +> [!NOTE] +> The Files API is currently in beta. To use the Files API, you’ll need to include the beta feature header: `anthropic-beta: files-api-2025-04-14` + #### Create a File You can create a file using the Files API in several ways: From 09f56c49598d29992521ee31f378b440e1acb9d2 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:37:54 -0500 Subject: [PATCH 08/21] tests: add tests for anthropic file model --- src/AnthropicClient/Models/AnthropicFile.cs | 2 +- .../Unit/Models/AnthropicFileTests.cs | 95 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs diff --git a/src/AnthropicClient/Models/AnthropicFile.cs b/src/AnthropicClient/Models/AnthropicFile.cs index a736a39..7a10a35 100644 --- a/src/AnthropicClient/Models/AnthropicFile.cs +++ b/src/AnthropicClient/Models/AnthropicFile.cs @@ -17,7 +17,7 @@ public class AnthropicFile /// Object type. /// [JsonPropertyName("type")] - public string Type { get; init; } = "file"; + public string Type { get; init; } = string.Empty; /// /// Original filename of the uploaded file. diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs new file mode 100644 index 0000000..cd679c9 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs @@ -0,0 +1,95 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AnthropicFileTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""id"": ""file-123"", + ""type"": ""file"", + ""filename"": ""test.txt"", + ""created_at"": ""2023-10-01T00:00:00Z"", + ""size_bytes"": 1024, + ""mime_type"": ""text/plain"", + ""downloadable"": true + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var result = new AnthropicFile(); + + result.Id.Should().BeEmpty(); + result.Type.Should().BeEmpty("file"); + result.Name.Should().BeEmpty(); + result.CreatedAt.Should().Be(DateTimeOffset.MinValue); + result.Size.Should().Be(0); + result.MimeType.Should().BeEmpty(); + result.Downloadable.Should().BeFalse(); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldInitializeProperties() + { + var file = new AnthropicFile + { + Id = "file-123", + Type = "file", + Name = "test.txt", + CreatedAt = new DateTimeOffset(2023, 10, 1, 0, 0, 0, TimeSpan.Zero), + Size = 1024, + MimeType = "text/plain", + Downloadable = true + }; + + file.Id.Should().Be("file-123"); + file.Type.Should().Be("file"); + file.Name.Should().Be("test.txt"); + file.CreatedAt.Should().Be(new DateTimeOffset(2023, 10, 1, 0, 0, 0, TimeSpan.Zero)); + file.Size.Should().Be(1024); + file.MimeType.Should().Be("text/plain"); + file.Downloadable.Should().BeTrue(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var file = new AnthropicFile + { + Id = "file-123", + Type = "file", + Name = "test.txt", + CreatedAt = new DateTimeOffset(2023, 10, 1, 0, 0, 0, TimeSpan.Zero), + Size = 1024, + MimeType = "text/plain", + Downloadable = true + }; + + var json = Serialize(file); + + var expectedJson = @"{ + ""id"": ""file-123"", + ""type"": ""file"", + ""filename"": ""test.txt"", + ""created_at"": ""2023-10-01T00:00:00+00:00"", + ""size_bytes"": 1024, + ""mime_type"": ""text/plain"", + ""downloadable"": true + }"; + + JsonAssert.Equal(expectedJson, json, true); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedValues() + { + var file = Deserialize(_testJson); + + file.Should().NotBeNull(); + file!.Id.Should().Be("file-123"); + file.Type.Should().Be("file"); + file.Name.Should().Be("test.txt"); + file.CreatedAt.Should().Be(new DateTimeOffset(2023, 10, 1, 0, 0, 0, TimeSpan.Zero)); + file.Size.Should().Be(1024); + file.MimeType.Should().Be("text/plain"); + file.Downloadable.Should().BeTrue(); + } +} \ No newline at end of file From 26908d36539dea46b1dc1c06bdf0e1ec76f32840 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:12:37 -0500 Subject: [PATCH 09/21] feat: implement listing a page of files --- src/AnthropicClient/AnthropicApiClient.cs | 9 ++ src/AnthropicClient/IAnthropicApiClient.cs | 8 ++ .../EndToEnd/AnthropicApiClientTests.cs | 18 +++ .../Integration/AnthropicApiClientTests.cs | 103 ++++++++++++++++++ .../Integration/IntegrationTest.cs | 6 + 5 files changed, 144 insertions(+) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index cfcb63c..95d7e01 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -388,6 +388,15 @@ public async Task> CreateFileAsync(CreateFileRequ return await CreateResultAsync(response); } + /// + public async Task>> ListFilesAsync(PagingRequest? request = null, CancellationToken cancellationToken = default) + { + var pagingRequest = request ?? new PagingRequest(); + var endpoint = $"{FilesEndpoint}?{pagingRequest.ToQueryParameters()}"; + var response = await SendRequestAsync(endpoint, cancellationToken: cancellationToken); + return await CreateResultAsync>(response); + } + private async IAsyncEnumerable>> GetAllPagesAsync(string endpoint, int limit = 20, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var pagingRequest = new PagingRequest(limit: limit); diff --git a/src/AnthropicClient/IAnthropicApiClient.cs b/src/AnthropicClient/IAnthropicApiClient.cs index 0dee03f..bae3b70 100644 --- a/src/AnthropicClient/IAnthropicApiClient.cs +++ b/src/AnthropicClient/IAnthropicApiClient.cs @@ -119,4 +119,12 @@ public interface IAnthropicApiClient /// A token to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the response as an where T is . Task> CreateFileAsync(CreateFileRequest request, CancellationToken cancellationToken = default); + + /// + /// Lists files asynchronously, returning a single page of results. + /// + /// The paging request to use for listing the files. + /// A token to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . + Task>> ListFilesAsync(PagingRequest? request = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 1b85111..568bd64 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -708,4 +708,22 @@ public async Task CreateFileAsync_WhenCalled_ItShouldReturnResponse() result.Value.Name.Should().Be(fileName); result.Value.MimeType.Should().Be(fileType); } + + [Fact] + public async Task ListFilesAsync_WhenCalled_ItShouldReturnPageOfFiles() + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + var fileBytes = await File.ReadAllBytesAsync(TestFileHelper.GetTestFilePath("story.txt")); + var createFileRequest = new CreateFileRequest(fileBytes, "story.txt", "text/plain"); + var createdFile = await client.CreateFileAsync(createFileRequest); + + var result = await client.ListFilesAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.Data.Should().ContainSingle(f => f.Id == createdFile.Value.Id); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index ff05315..97a1ef7 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1965,4 +1965,107 @@ public async Task CreateFileAsync_WhenCalled_ItShouldReturnFile() Type = "file" }); } + + [Fact] + public async Task ListFilesAsync_WhenCalled_ItShouldReturnPageOfFiles() + { + _mockHttpMessageHandler + .WhenListFilesRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""created_at"": ""2023-11-07T05:31:56Z"", + ""downloadable"": false, + ""filename"": ""example.txt"", + ""id"": ""file_013Zva2CMHLNnXjNJJKqJ2EF"", + ""mime_type"": ""text/plain"", + ""size_bytes"": 1234, + ""type"": ""file"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + var result = await Client.ListFilesAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.HasMore.Should().BeTrue(); + result.Value.FirstId.Should().Be("1"); + result.Value.LastId.Should().Be("1"); + result.Value.Data.Should().BeEquivalentTo(new AnthropicFile[] + { + new() + { + CreatedAt = DateTimeOffset.Parse("2023-11-07T05:31:56Z"), + Downloadable = false, + Name = "example.txt", + Id = "file_013Zva2CMHLNnXjNJJKqJ2EF", + MimeType = "text/plain", + Size = 1234, + Type = "file" + } + }); + } + + [Fact] + public async Task ListFilesAsync_WhenCalledWithPagingRequest_ItShouldReturnPageOfFiles() + { + var pagingRequest = new PagingRequest(afterId: "next_id", limit: 10); + + _mockHttpMessageHandler + .WhenListFilesRequest() + .WithQueryString(new Dictionary + { + { "after_id", pagingRequest.AfterId }, + { "limit", pagingRequest.Limit.ToString() }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""created_at"": ""2023-11-07T05:31:56Z"", + ""downloadable"": false, + ""filename"": ""example.txt"", + ""id"": ""file_013Zva2CMHLNnXjNJJKqJ2EF"", + ""mime_type"": ""text/plain"", + ""size_bytes"": 1234, + ""type"": ""file"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + var result = await Client.ListFilesAsync(pagingRequest); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.HasMore.Should().BeTrue(); + result.Value.FirstId.Should().Be("1"); + result.Value.LastId.Should().Be("1"); + result.Value.Data.Should().BeEquivalentTo(new AnthropicFile[] + { + new() + { + CreatedAt = DateTimeOffset.Parse("2023-11-07T05:31:56Z"), + Downloadable = false, + Name = "example.txt", + Id = "file_013Zva2CMHLNnXjNJJKqJ2EF", + MimeType = "text/plain", + Size = 1234, + Type = "file" + } + }); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index f618b44..19c5a78 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -110,4 +110,10 @@ public static MockedRequest WhenCreateFileRequest(this MockHttpMessageHandler mo return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Post, FilesEndpoint); } + + public static MockedRequest WhenListFilesRequest(this MockHttpMessageHandler mockHttpMessageHandler) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, FilesEndpoint); + } } \ No newline at end of file From 9cf50e1b2c73958a97caea67db8afcd445cc21b5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:33:11 -0500 Subject: [PATCH 10/21] feat: implement list all files method --- src/AnthropicClient/AnthropicApiClient.cs | 9 ++ src/AnthropicClient/IAnthropicApiClient.cs | 8 ++ .../EndToEnd/AnthropicApiClientTests.cs | 18 ++++ .../Integration/AnthropicApiClientTests.cs | 99 +++++++++++++++++++ 4 files changed, 134 insertions(+) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 95d7e01..bcfb50a 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -397,6 +397,15 @@ public async Task>> ListFilesAsync(PagingReq return await CreateResultAsync>(response); } + /// + public async IAsyncEnumerable>> ListAllFilesAsync(int limit = 20, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var result in GetAllPagesAsync(FilesEndpoint, limit, cancellationToken)) + { + yield return result; + } + } + private async IAsyncEnumerable>> GetAllPagesAsync(string endpoint, int limit = 20, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var pagingRequest = new PagingRequest(limit: limit); diff --git a/src/AnthropicClient/IAnthropicApiClient.cs b/src/AnthropicClient/IAnthropicApiClient.cs index bae3b70..547879f 100644 --- a/src/AnthropicClient/IAnthropicApiClient.cs +++ b/src/AnthropicClient/IAnthropicApiClient.cs @@ -127,4 +127,12 @@ public interface IAnthropicApiClient /// A token to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . Task>> ListFilesAsync(PagingRequest? request = null, CancellationToken cancellationToken = default); + + /// + /// Lists all files asynchronously, returning every page of results. + /// + /// The maximum number of files to return in each page. + /// A token to cancel the asynchronous operation. + /// An asynchronous enumerable that yields the response as an where T is where T is . + IAsyncEnumerable>> ListAllFilesAsync(int limit = 20, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 568bd64..d86fc4a 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -726,4 +726,22 @@ public async Task ListFilesAsync_WhenCalled_ItShouldReturnPageOfFiles() result.Value.Should().BeOfType>(); result.Value.Data.Should().ContainSingle(f => f.Id == createdFile.Value.Id); } + + [Fact] + public async Task ListAllFilesAsync_WhenCalled_ItShouldReturnAllFiles() + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + var fileBytes = await File.ReadAllBytesAsync(TestFileHelper.GetTestFilePath("story.txt")); + var createFileRequest = new CreateFileRequest(fileBytes, "story.txt", "text/plain"); + var createdFile = await client.CreateFileAsync(createFileRequest); + + var responses = await client.ListAllFilesAsync(limit: 1).ToListAsync(); + + responses.Should().HaveCountGreaterThan(0); + responses.Select(r => r.Value).SelectMany(p => p.Data) + .Should().ContainSingle(f => f.Id == createdFile.Value.Id); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 97a1ef7..d9197ac 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -2068,4 +2068,103 @@ public async Task ListFilesAsync_WhenCalledWithPagingRequest_ItShouldReturnPageO } }); } + + [Fact] + public async Task ListAllFilesAsync_WhenCalled_ItShouldReturnAllFiles() + { + _mockHttpMessageHandler + .WhenListFilesRequest() + .WithExactQueryString(new Dictionary() + { + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""created_at"": ""2023-11-07T05:31:56Z"", + ""downloadable"": false, + ""filename"": ""example.txt"", + ""id"": ""file_013Zva2CMHLNnXjNJJKqJ2EF"", + ""mime_type"": ""text/plain"", + ""size_bytes"": 1234, + ""type"": ""file"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + _mockHttpMessageHandler + .WhenListFilesRequest() + .WithExactQueryString(new Dictionary() + { + { "after_id", "1" }, + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""created_at"": ""2023-11-07T05:31:56Z"", + ""downloadable"": false, + ""filename"": ""example.txt"", + ""id"": ""file_013Zva2CMHLNnXjNJJKqJ2EF"", + ""mime_type"": ""text/plain"", + ""size_bytes"": 1234, + ""type"": ""file"" + } + ], + ""has_more"": false, + ""first_id"": ""2"", + ""last_id"": ""2"" + }" + ); + + var pageResponses = Client.ListAllFilesAsync(); + var collectedPages = new List>(); + + await foreach (var response in pageResponses) + { + response.IsSuccess.Should().BeTrue(); + response.Value.Should().BeOfType>(); + collectedPages.Add(response.Value); + } + + var expectedFile = new AnthropicFile() + { + CreatedAt = DateTimeOffset.Parse("2023-11-07T05:31:56Z"), + Downloadable = false, + Name = "example.txt", + Id = "file_013Zva2CMHLNnXjNJJKqJ2EF", + MimeType = "text/plain", + Size = 1234, + Type = "file" + }; + + collectedPages.Should().HaveCount(2); + collectedPages.Should().BeEquivalentTo(new List>() + { + new() + { + Data = [expectedFile], + FirstId = "1", + LastId = "1", + HasMore = true + }, + new() + { + Data = [expectedFile], + FirstId = "2", + LastId = "2", + HasMore = false + } + }); + } } \ No newline at end of file From 63278bbeadc53d67d8e6e66733d13b2079eb27ac Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 18:46:32 -0500 Subject: [PATCH 11/21] feat: implement `GetFileInfoAsync`, `GetFileAsync`, and `DeleteFileAsync` --- src/AnthropicClient/AnthropicApiClient.cs | 65 ++++++++++++++ src/AnthropicClient/IAnthropicApiClient.cs | 25 ++++++ .../Models/AnthropicFileDeleteResponse.cs | 17 ++++ .../EndToEnd/AnthropicApiClientTests.cs | 36 ++++++++ .../Integration/AnthropicApiClientTests.cs | 84 +++++++++++++++++++ .../Integration/IntegrationTest.cs | 18 ++++ .../AnthropicFileDeleteResponseTests.cs | 58 +++++++++++++ 7 files changed, 303 insertions(+) create mode 100644 src/AnthropicClient/Models/AnthropicFileDeleteResponse.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AnthropicFileDeleteResponseTests.cs diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index bcfb50a..1870fbd 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -385,6 +385,14 @@ public async Task> GetModelAsync(string modelId, public async Task> CreateFileAsync(CreateFileRequest request, CancellationToken cancellationToken = default) { var response = await SendFileRequestAsync(FilesEndpoint, request, cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + var content = await response.Content.ReadAsStringAsync(); + var error = Deserialize(content) ?? new AnthropicError(); + return AnthropicResult.Failure(error, new AnthropicHeaders(response.Headers)); + } + return await CreateResultAsync(response); } @@ -394,6 +402,14 @@ public async Task>> ListFilesAsync(PagingReq var pagingRequest = request ?? new PagingRequest(); var endpoint = $"{FilesEndpoint}?{pagingRequest.ToQueryParameters()}"; var response = await SendRequestAsync(endpoint, cancellationToken: cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + var content = await response.Content.ReadAsStringAsync(); + var error = Deserialize(content) ?? new AnthropicError(); + return AnthropicResult>.Failure(error, new AnthropicHeaders(response.Headers)); + } + return await CreateResultAsync>(response); } @@ -406,6 +422,55 @@ public async IAsyncEnumerable>> ListAllFiles } } + /// + public async Task> GetFileInfoAsync(string fileId, CancellationToken cancellationToken = default) + { + var endpoint = $"{FilesEndpoint}/{fileId}"; + var response = await SendRequestAsync(endpoint, cancellationToken: cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + var content = await response.Content.ReadAsStringAsync(); + var error = Deserialize(content) ?? new AnthropicError(); + return AnthropicResult.Failure(error, new AnthropicHeaders(response.Headers)); + } + + return await CreateResultAsync(response); + } + + /// + public async Task> GetFileAsync(string fileId, CancellationToken cancellationToken = default) + { + var endpoint = $"{FilesEndpoint}/{fileId}/content"; + var response = await SendRequestAsync(endpoint, cancellationToken: cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + var content = await response.Content.ReadAsStringAsync(); + var error = Deserialize(content) ?? new AnthropicError(); + return AnthropicResult.Failure(error, new AnthropicHeaders(response.Headers)); + } + + var stream = await response.Content.ReadAsStreamAsync(); + return AnthropicResult.Success(stream, new AnthropicHeaders(response.Headers)); + } + + /// + public async Task> DeleteFileAsync(string fileId, CancellationToken cancellationToken = default) + { + var endpoint = $"{FilesEndpoint}/{fileId}"; + var response = await SendRequestAsync(endpoint, HttpMethod.Delete, cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + var content = await response.Content.ReadAsStringAsync(); + var error = Deserialize(content) ?? new AnthropicError(); + return AnthropicResult.Failure(error, new AnthropicHeaders(response.Headers)); + } + + return await CreateResultAsync(response); + } + private async IAsyncEnumerable>> GetAllPagesAsync(string endpoint, int limit = 20, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var pagingRequest = new PagingRequest(limit: limit); diff --git a/src/AnthropicClient/IAnthropicApiClient.cs b/src/AnthropicClient/IAnthropicApiClient.cs index 547879f..89e3f7c 100644 --- a/src/AnthropicClient/IAnthropicApiClient.cs +++ b/src/AnthropicClient/IAnthropicApiClient.cs @@ -135,4 +135,29 @@ public interface IAnthropicApiClient /// A token to cancel the asynchronous operation. /// An asynchronous enumerable that yields the response as an where T is where T is . IAsyncEnumerable>> ListAllFilesAsync(int limit = 20, CancellationToken cancellationToken = default); + + /// + /// Gets a file's metadata by its ID asynchronously. + /// + /// The ID of the file to get. + /// A token to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> GetFileInfoAsync(string fileId, CancellationToken cancellationToken = default); + + /// + /// Gets a file's content by its ID asynchronously. + /// + /// The ID of the file to get the content for. + /// A token to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is a stream containing the file content. + Task> GetFileAsync(string fileId, CancellationToken cancellationToken = default); + + + /// + /// Deletes a file by its ID asynchronously. + /// + /// The ID of the file to delete. + /// A token to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> DeleteFileAsync(string fileId, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/AnthropicClient/Models/AnthropicFileDeleteResponse.cs b/src/AnthropicClient/Models/AnthropicFileDeleteResponse.cs new file mode 100644 index 0000000..346d00b --- /dev/null +++ b/src/AnthropicClient/Models/AnthropicFileDeleteResponse.cs @@ -0,0 +1,17 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the response from deleting a file in the Anthropic API. +/// +public class AnthropicFileDeleteResponse +{ + /// + /// Gets or sets the ID of the file that was deleted. + /// + public string Id { get; init; } = string.Empty; + + /// + /// Gets or sets the response type + /// + public string Type { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index d86fc4a..da6a61f 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -744,4 +744,40 @@ public async Task ListAllFilesAsync_WhenCalled_ItShouldReturnAllFiles() responses.Select(r => r.Value).SelectMany(p => p.Data) .Should().ContainSingle(f => f.Id == createdFile.Value.Id); } + + [Fact] + public async Task GetFileInfoAsync_WhenCalled_ItShouldReturnFile() + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + var fileBytes = await File.ReadAllBytesAsync(TestFileHelper.GetTestFilePath("story.txt")); + var createFileRequest = new CreateFileRequest(fileBytes, "story.txt", "text/plain"); + var createdFile = await client.CreateFileAsync(createFileRequest); + + var result = await client.GetFileInfoAsync(createdFile.Value.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be(createdFile.Value.Id); + } + + [Fact] + public async Task DeleteFileAsync_WhenCalled_ItShouldReturnDeleteResponse() + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + var fileBytes = await File.ReadAllBytesAsync(TestFileHelper.GetTestFilePath("story.txt")); + var createFileRequest = new CreateFileRequest(fileBytes, "story.txt", "text/plain"); + var createdFile = await client.CreateFileAsync(createFileRequest); + + var result = await client.DeleteFileAsync(createdFile.Value.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be(createdFile.Value.Id); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index d9197ac..66ea05e 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -2167,4 +2167,88 @@ public async Task ListAllFilesAsync_WhenCalled_ItShouldReturnAllFiles() } }); } + + [Fact] + public async Task GetFileInfoAsync_WhenCalled_ItShouldReturnFile() + { + var fileId = "file_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetFileRequest(fileId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""created_at"": ""2023-11-07T05:31:56Z"", + ""downloadable"": false, + ""filename"": ""example.txt"", + ""id"": ""file_013Zva2CMHLNnXjNJJKqJ2EF"", + ""mime_type"": ""text/plain"", + ""size_bytes"": 1234, + ""type"": ""file"" + }" + ); + + var result = await Client.GetFileInfoAsync(fileId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEquivalentTo(new AnthropicFile() + { + CreatedAt = DateTimeOffset.Parse("2023-11-07T05:31:56Z"), + Downloadable = false, + Name = "example.txt", + Id = "file_013Zva2CMHLNnXjNJJKqJ2EF", + MimeType = "text/plain", + Size = 1234, + Type = "file" + }); + } + + [Fact] + public async Task GetFileAsync_WhenCalled_ItShouldReturnFileContent() + { + var fileId = "file_013Zva2CMHLNnXjNJJKqJ2EF"; + var fileContent = new MemoryStream(Encoding.UTF8.GetBytes("Example file content")); + + _mockHttpMessageHandler + .WhenGetFileContentRequest(fileId) + .Respond( + HttpStatusCode.OK, + "application/octet-stream", + fileContent + ); + + var result = await Client.GetFileAsync(fileId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeAssignableTo(); + + using var streamReader = new StreamReader(result.Value); + var content = await streamReader.ReadToEndAsync(); + content.Should().Be("Example file content"); + } + + [Fact] + public async Task DeleteFileAsync_WhenCalled_ItShouldReturnDeletionResponse() + { + var fileId = "file_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenDeleteFileRequest(fileId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""id"": ""file_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""file_deleted"" + }" + ); + + var result = await Client.DeleteFileAsync(fileId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be(fileId); + result.Value.Type.Should().Be("file_deleted"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index 19c5a78..dcc339b 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -116,4 +116,22 @@ public static MockedRequest WhenListFilesRequest(this MockHttpMessageHandler moc return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Get, FilesEndpoint); } + + public static MockedRequest WhenGetFileRequest(this MockHttpMessageHandler mockHttpMessageHandler, string fileId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, $"{FilesEndpoint}/{fileId}"); + } + + public static MockedRequest WhenGetFileContentRequest(this MockHttpMessageHandler mockHttpMessageHandler, string fileId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, $"{FilesEndpoint}/{fileId}/content"); + } + + public static MockedRequest WhenDeleteFileRequest(this MockHttpMessageHandler mockHttpMessageHandler, string fileId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Delete, $"{FilesEndpoint}/{fileId}"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileDeleteResponseTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileDeleteResponseTests.cs new file mode 100644 index 0000000..750279b --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileDeleteResponseTests.cs @@ -0,0 +1,58 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AnthropicFileDeleteResponseTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""id"": ""file-12345"", + ""type"": ""file_deleted"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var response = new AnthropicFileDeleteResponse(); + + response.Id.Should().BeEmpty(); + response.Type.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldInitializePropertiesWithValues() + { + var id = "file-12345"; + var type = "file_deleted"; + + var response = new AnthropicFileDeleteResponse + { + Id = id, + Type = type + }; + + response.Id.Should().Be(id); + response.Type.Should().Be(type); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldMatchExpectedJson() + { + var response = new AnthropicFileDeleteResponse + { + Id = "file-12345", + Type = "file_deleted" + }; + + var json = JsonSerializer.Serialize(response, JsonSerializationOptions.DefaultOptions); + + JsonAssert.Equal(_testJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldMatchExpectedObject() + { + var response = JsonSerializer.Deserialize(_testJson, JsonSerializationOptions.DefaultOptions); + + response.Should().NotBeNull(); + response.Id.Should().Be("file-12345"); + response.Type.Should().Be("file_deleted"); + } +} \ No newline at end of file From 3a50c06ea424ead0e7f07fb6ceda72d7f8720dd9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:04:46 -0500 Subject: [PATCH 12/21] tests: add tests for sad path in new files methods --- .../Integration/AnthropicApiClientTests.cs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 66ea05e..168f552 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1966,6 +1966,33 @@ public async Task CreateFileAsync_WhenCalled_ItShouldReturnFile() }); } + [Fact] + public async Task CreateFileAsync_WhenCalledAndRequestFails_ItShouldReturnError() + { + _mockHttpMessageHandler + .WhenCreateFileRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""file: file size exceeds limit"" + } + }" + ); + + var fileContent = new MemoryStream(Encoding.UTF8.GetBytes("Example file content")); + var request = new CreateFileRequest(fileContent, "example.txt", "text/plain"); + + var result = await Client.CreateFileAsync(request); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + [Fact] public async Task ListFilesAsync_WhenCalled_ItShouldReturnPageOfFiles() { @@ -2014,6 +2041,30 @@ public async Task ListFilesAsync_WhenCalled_ItShouldReturnPageOfFiles() }); } + [Fact] + public async Task ListFilesAsync_WhenCalledAndRequestFails_ItShouldReturnError() + { + _mockHttpMessageHandler + .WhenListFilesRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""files: file not found"" + } + }" + ); + + var result = await Client.ListFilesAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + [Fact] public async Task ListFilesAsync_WhenCalledWithPagingRequest_ItShouldReturnPageOfFiles() { @@ -2204,6 +2255,32 @@ public async Task GetFileInfoAsync_WhenCalled_ItShouldReturnFile() }); } + [Fact] + public async Task GetFileInfoAsync_WhenCalledAndRequestFails_ItShouldReturnError() + { + var fileId = "file_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetFileRequest(fileId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""file: file not found"" + } + }" + ); + + var result = await Client.GetFileInfoAsync(fileId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + [Fact] public async Task GetFileAsync_WhenCalled_ItShouldReturnFileContent() { @@ -2228,6 +2305,32 @@ public async Task GetFileAsync_WhenCalled_ItShouldReturnFileContent() content.Should().Be("Example file content"); } + [Fact] + public async Task GetFileAsync_WhenCalledAndRequestFails_ItShouldReturnError() + { + var fileId = "file_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetFileContentRequest(fileId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""file: file not found"" + } + }" + ); + + var result = await Client.GetFileAsync(fileId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + [Fact] public async Task DeleteFileAsync_WhenCalled_ItShouldReturnDeletionResponse() { @@ -2251,4 +2354,30 @@ public async Task DeleteFileAsync_WhenCalled_ItShouldReturnDeletionResponse() result.Value.Id.Should().Be(fileId); result.Value.Type.Should().Be("file_deleted"); } + + [Fact] + public async Task DeleteFileAsync_WhenCalledAndRequestFails_ItShouldReturnError() + { + var fileId = "file_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenDeleteFileRequest(fileId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""file: file not found"" + } + }" + ); + + var result = await Client.DeleteFileAsync(fileId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } } \ No newline at end of file From 0a2514f6ae9d0bc2383693655a8a9995e4b2ec1e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:17:35 -0500 Subject: [PATCH 13/21] refactor: remove unnecessary if checks and add test to handle getting null file --- src/AnthropicClient/AnthropicApiClient.cs | 32 ------------------- .../Integration/AnthropicApiClientTests.cs | 20 ++++++++++++ 2 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 1870fbd..e872c38 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -385,14 +385,6 @@ public async Task> GetModelAsync(string modelId, public async Task> CreateFileAsync(CreateFileRequest request, CancellationToken cancellationToken = default) { var response = await SendFileRequestAsync(FilesEndpoint, request, cancellationToken); - - if (response.IsSuccessStatusCode is false) - { - var content = await response.Content.ReadAsStringAsync(); - var error = Deserialize(content) ?? new AnthropicError(); - return AnthropicResult.Failure(error, new AnthropicHeaders(response.Headers)); - } - return await CreateResultAsync(response); } @@ -402,14 +394,6 @@ public async Task>> ListFilesAsync(PagingReq var pagingRequest = request ?? new PagingRequest(); var endpoint = $"{FilesEndpoint}?{pagingRequest.ToQueryParameters()}"; var response = await SendRequestAsync(endpoint, cancellationToken: cancellationToken); - - if (response.IsSuccessStatusCode is false) - { - var content = await response.Content.ReadAsStringAsync(); - var error = Deserialize(content) ?? new AnthropicError(); - return AnthropicResult>.Failure(error, new AnthropicHeaders(response.Headers)); - } - return await CreateResultAsync>(response); } @@ -427,14 +411,6 @@ public async Task> GetFileInfoAsync(string fileId { var endpoint = $"{FilesEndpoint}/{fileId}"; var response = await SendRequestAsync(endpoint, cancellationToken: cancellationToken); - - if (response.IsSuccessStatusCode is false) - { - var content = await response.Content.ReadAsStringAsync(); - var error = Deserialize(content) ?? new AnthropicError(); - return AnthropicResult.Failure(error, new AnthropicHeaders(response.Headers)); - } - return await CreateResultAsync(response); } @@ -460,14 +436,6 @@ public async Task> DeleteFileAsync( { var endpoint = $"{FilesEndpoint}/{fileId}"; var response = await SendRequestAsync(endpoint, HttpMethod.Delete, cancellationToken); - - if (response.IsSuccessStatusCode is false) - { - var content = await response.Content.ReadAsStringAsync(); - var error = Deserialize(content) ?? new AnthropicError(); - return AnthropicResult.Failure(error, new AnthropicHeaders(response.Headers)); - } - return await CreateResultAsync(response); } diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 168f552..c0ee144 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -2331,6 +2331,26 @@ public async Task GetFileAsync_WhenCalledAndRequestFails_ItShouldReturnError() result.Error.Error.Should().BeOfType(); } + [Fact] + public async Task GetFileAsync_WhenCalledAndCanNotDeserializeResponse_ItShouldReturnError() + { + var fileId = "file_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetFileContentRequest(fileId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"null" + ); + + var result = await Client.GetFileAsync(fileId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + [Fact] public async Task DeleteFileAsync_WhenCalled_ItShouldReturnDeletionResponse() { From e821b3579c84157442405f1a49daa507a26f9b3a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:23:05 -0500 Subject: [PATCH 14/21] chore: run dotnet format --- src/AnthropicClient/Models/AnthropicFile.cs | 2 +- src/AnthropicClient/Models/CreateFileRequest.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AnthropicClient/Models/AnthropicFile.cs b/src/AnthropicClient/Models/AnthropicFile.cs index 7a10a35..209fae7 100644 --- a/src/AnthropicClient/Models/AnthropicFile.cs +++ b/src/AnthropicClient/Models/AnthropicFile.cs @@ -48,4 +48,4 @@ public class AnthropicFile /// [JsonPropertyName("downloadable")] public bool Downloadable { get; init; } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CreateFileRequest.cs b/src/AnthropicClient/Models/CreateFileRequest.cs index 535c9a3..1061bd4 100644 --- a/src/AnthropicClient/Models/CreateFileRequest.cs +++ b/src/AnthropicClient/Models/CreateFileRequest.cs @@ -63,4 +63,4 @@ public CreateFileRequest(Stream stream, string fileName, string fileType) FileName = fileName; FileType = fileType; } -} +} \ No newline at end of file From 1f304b851e7cb38300446dee15b4728aba917f1f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:37:42 -0500 Subject: [PATCH 15/21] fix: use sync copy to method --- src/AnthropicClient/Models/CreateFileRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AnthropicClient/Models/CreateFileRequest.cs b/src/AnthropicClient/Models/CreateFileRequest.cs index 1061bd4..0860f96 100644 --- a/src/AnthropicClient/Models/CreateFileRequest.cs +++ b/src/AnthropicClient/Models/CreateFileRequest.cs @@ -56,7 +56,7 @@ public CreateFileRequest(Stream stream, string fileName, string fileType) ArgumentValidator.ThrowIfNullOrWhitespace(fileType, nameof(fileType)); using var memoryStream = new MemoryStream(); - stream.CopyToAsync(memoryStream); + stream.CopyTo(memoryStream); var fileContent = memoryStream.ToArray(); File = fileContent; From 348976d1f11fa91d2082483413fa3866f2e40987 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:37:53 -0500 Subject: [PATCH 16/21] docs: update files api section with examples for each method --- README.md | 141 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 108 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index dcc1786..531d194 100644 --- a/README.md +++ b/README.md @@ -215,55 +215,130 @@ You can create a file using the Files API in several ways: ##### From a Byte Array ```csharp -using AnthropicClient; -using AnthropicClient.Models; -using System.Text; +var fileBytes = await File.ReadAllBytesAsync("path/to/file.txt"); +var request = new CreateFileRequest(fileBytes, "file.txt", "text/plain"); +var result = await client.CreateFileAsync(request); -// Create a file from a byte array -var content = "This is a sample text file for the Anthropic Files API."; -var fileBytes = Encoding.UTF8.GetBytes(content); +if (result.IsSuccess) +{ + var file = result.Value; + Console.WriteLine($"Created file: {file.Name} (ID: {file.Id})"); +} +``` -var request = new CreateFileRequest(fileBytes, "sample.txt", "text/plain"); -var response = await client.CreateFileAsync(request); +##### From a Stream -if (response.IsSuccess) +```csharp +using var fileStream = File.OpenRead("path/to/file.txt"); +var request = new CreateFileRequest(fileStream, "file.txt", "text/plain"); + +var result = await client.CreateFileAsync(request); + +if (result.IsSuccess) { - Console.WriteLine("File created successfully from byte array!"); - Console.WriteLine("File ID: {0}", response.Value.Id); - Console.WriteLine("File Name: {0}", response.Value.FileName); - Console.WriteLine("File Type: {0}", response.Value.FileType); - Console.WriteLine("File Size: {0} bytes", response.Value.SizeBytes); + var file = result.Value; + Console.WriteLine($"Created file: {file.Name} (ID: {file.Id})"); } -else +``` + +#### List Files + +You can list files in your account using pagination: + +##### Single Page + +```csharp +var result = await client.ListFilesAsync(); + +if (result.IsSuccess) { - Console.WriteLine("Failed to create file"); - Console.WriteLine("Error Type: {0}", response.Error.Error.Type); - Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + var page = result.Value; + Console.WriteLine($"Found {page.Data.Count} files"); + + foreach (var file in page.Data) + { + Console.WriteLine($"- {file.Name} (ID: {file.Id}, Size: {file.Size} bytes)"); + } + + if (page.HasMore) + { + Console.WriteLine("More files available..."); + } } ``` -##### From a Stream +##### With Pagination Options ```csharp -using AnthropicClient; -using AnthropicClient.Models; -using System.Text; +var pagingRequest = new PagingRequest(afterId: "file_12345", limit: 10); +var result = await client.ListFilesAsync(pagingRequest); +``` -// Create a file from a stream -using var stream = new MemoryStream(Encoding.UTF8.GetBytes("Stream content")); -var request = new CreateFileRequest(stream, "stream-file.txt", "text/plain"); -var response = await client.CreateFileAsync(request); +##### All Files (Multiple Pages) -if (response.IsSuccess) +```csharp +await foreach (var pageResult in client.ListAllFilesAsync(limit: 20)) { - Console.WriteLine("File created successfully from stream!"); - Console.WriteLine("File ID: {0}", response.Value.Id); + if (pageResult.IsSuccess) + { + var page = pageResult.Value; + foreach (var file in page.Data) + { + Console.WriteLine($"- {file.Name} (ID: {file.Id})"); + } + } } -else +``` + +#### Get File Information + +Retrieve metadata about a specific file: + +```csharp +var result = await client.GetFileInfoAsync("file_12345"); + +if (result.IsSuccess) { - Console.WriteLine("Failed to create file"); - Console.WriteLine("Error Type: {0}", response.Error.Error.Type); - Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + var file = result.Value; + Console.WriteLine($"File: {file.Name}"); + Console.WriteLine($"ID: {file.Id}"); + Console.WriteLine($"MIME Type: {file.MimeType}"); + Console.WriteLine($"Size: {file.Size} bytes"); + Console.WriteLine($"Created: {file.CreatedAt}"); + Console.WriteLine($"Downloadable: {file.Downloadable}"); +} +``` + +#### Get File Content + +Download the content of a file as a stream: + +```csharp +var result = await client.GetFileAsync("file_12345"); + +if (result.IsSuccess) +{ + using var contentStream = result.Value; + using var reader = new StreamReader(contentStream); + var content = await reader.ReadToEndAsync(); + + Console.WriteLine("File content:"); + Console.WriteLine(content); +} +``` + +#### Delete a File + +Remove a file from your account: + +```csharp +var result = await client.DeleteFileAsync("file_12345"); + +if (result.IsSuccess) +{ + var deleteResponse = result.Value; + Console.WriteLine($"Deleted file: {deleteResponse.Id}"); + Console.WriteLine($"Type: {deleteResponse.Type}"); } ``` From e0c6e598c812c8c1a19c60b7c66d77b9a87ff955 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:41:50 -0500 Subject: [PATCH 17/21] tests: remove unnecessary reason string --- tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs index cd679c9..f356209 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicFileTests.cs @@ -18,7 +18,7 @@ public void Constructor_WhenCalled_ItShouldInitializeProperties() var result = new AnthropicFile(); result.Id.Should().BeEmpty(); - result.Type.Should().BeEmpty("file"); + result.Type.Should().BeEmpty(); result.Name.Should().BeEmpty(); result.CreatedAt.Should().Be(DateTimeOffset.MinValue); result.Size.Should().Be(0); From 45df2db47d34d0cf638687f9135685bff3c03dc6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:01:23 -0500 Subject: [PATCH 18/21] feat: add support for file source and url source --- src/AnthropicClient/Json/SourceConverter.cs | 14 ++ src/AnthropicClient/Models/FileSource.cs | 33 +++ src/AnthropicClient/Models/ImageContent.cs | 28 +++ src/AnthropicClient/Models/SourceType.cs | 10 + src/AnthropicClient/Models/UrlSource.cs | 30 +++ .../EndToEnd/AnthropicApiClientTests.cs | 214 +++++++++++++++--- .../Unit/Models/FileSourceTests.cs | 52 +++++ .../Unit/Models/ImageContentTests.cs | 45 +++- .../Unit/Models/SourceTypeTests.cs | 6 + .../Unit/Models/UrlSourceTests.cs | 64 ++++++ 10 files changed, 469 insertions(+), 27 deletions(-) create mode 100644 src/AnthropicClient/Models/FileSource.cs create mode 100644 src/AnthropicClient/Models/UrlSource.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs diff --git a/src/AnthropicClient/Json/SourceConverter.cs b/src/AnthropicClient/Json/SourceConverter.cs index a68fd70..190d93a 100644 --- a/src/AnthropicClient/Json/SourceConverter.cs +++ b/src/AnthropicClient/Json/SourceConverter.cs @@ -16,6 +16,8 @@ public override Source Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS { SourceType.Text => JsonSerializer.Deserialize(root.GetRawText(), options)!, SourceType.Content => JsonSerializer.Deserialize(root.GetRawText(), options)!, + SourceType.File => JsonSerializer.Deserialize(root.GetRawText(), options)!, + SourceType.Url => JsonSerializer.Deserialize(root.GetRawText(), options)!, SourceType.Base64 => DeserializeBase64Source(root, options), _ => throw new JsonException($"Unknown source type: {type}") }; @@ -54,6 +56,18 @@ public override void Write(Utf8JsonWriter writer, Source value, JsonSerializerOp return; } + if (value is FileSource fileSource) + { + JsonSerializer.Serialize(writer, fileSource, options); + return; + } + + if (value is UrlSource urlSource) + { + JsonSerializer.Serialize(writer, urlSource, options); + return; + } + JsonSerializer.Serialize(writer, value, value.GetType(), options); } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/FileSource.cs b/src/AnthropicClient/Models/FileSource.cs new file mode 100644 index 0000000..106bb2f --- /dev/null +++ b/src/AnthropicClient/Models/FileSource.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a file source in the Anthropic API. +/// +public class FileSource : Source +{ + /// + /// Gets or sets the unique identifier for the file source. + /// + [JsonPropertyName("file_id")] + public string Id { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// A new instance of with the type set to "file". + public FileSource() : base(SourceType.File) + { + } + + /// + /// Initializes a new instance of the class with a specified file ID. + /// + /// The unique identifier for the file source. + /// A new instance of . + public FileSource(string id) : base(SourceType.File) + { + Id = id; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ImageContent.cs b/src/AnthropicClient/Models/ImageContent.cs index 1d4a949..6393db1 100644 --- a/src/AnthropicClient/Models/ImageContent.cs +++ b/src/AnthropicClient/Models/ImageContent.cs @@ -53,4 +53,32 @@ public ImageContent(string mediaType, string data, CacheControl cacheControl) : Source = new ImageSource(mediaType, data); } + + /// + /// Initializes a new instance of the class. + /// + /// The source of the image. + /// A new instance of the class. + /// Thrown when the source is null. + public ImageContent(Source source) : base(ContentType.Image) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + + Source = source; + } + + /// + /// Initializes a new instance of the class. + /// + /// The source of the image. + /// The cache control to be used for the content. + /// A new instance of the class. + /// Thrown when the source or cache control is null. + public ImageContent(Source source, CacheControl cacheControl) : base(ContentType.Image, cacheControl) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + ArgumentValidator.ThrowIfNull(cacheControl, nameof(cacheControl)); + + Source = source; + } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/SourceType.cs b/src/AnthropicClient/Models/SourceType.cs index 7079bbe..0eb9e3a 100644 --- a/src/AnthropicClient/Models/SourceType.cs +++ b/src/AnthropicClient/Models/SourceType.cs @@ -19,4 +19,14 @@ public static class SourceType /// The text document source type. /// public const string Text = "text"; + + /// + /// The file document source type. + /// + public const string File = "file"; + + /// + /// The URL document source type. + /// + public const string Url = "url"; } \ No newline at end of file diff --git a/src/AnthropicClient/Models/UrlSource.cs b/src/AnthropicClient/Models/UrlSource.cs new file mode 100644 index 0000000..f9e343d --- /dev/null +++ b/src/AnthropicClient/Models/UrlSource.cs @@ -0,0 +1,30 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a URL source in the Anthropic API. +/// +public class UrlSource : Source +{ + /// + /// Gets or sets the URL of the source document. + /// + public string Url { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// A new instance of with the type set to "url". + public UrlSource() : base(SourceType.Url) + { + } + + /// + /// Initializes a new instance of the class with a specified URL. + /// + /// The URL of the source document. + /// A new instance of . + public UrlSource(string url) : base(SourceType.Url) + { + Url = url; + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index da6a61f..e7b289c 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -2,8 +2,15 @@ namespace AnthropicClient.Tests.EndToEnd; -public class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndToEndTest(configFixture) +public class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndToEndTest(configFixture), IAsyncLifetime { + private readonly List _filesToDelete = []; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + [Fact] public async Task CreateMessageAsync_WhenCalled_ItShouldReturnResponse() { @@ -97,10 +104,40 @@ public async Task CreateMessageAsync_WhenImageIsSent_ItShouldReturnResponse() } [Fact] - public async Task CreateMessageAsync_WhenSystemMessagesContainCacheControl_ItShouldUseCache() + public async Task CreateMessageAsync_WhenImageIsSentAsUrl_ItShouldReturnResponse() { - var client = CreateClient(new HttpClient()); + var request = new MessageRequest( + model: AnthropicModels.Claude3Haiku, + messages: [ + new(MessageRole.User, [ + new ImageContent(new UrlSource("https://ftp.stevanfreeborn.com/share/anthropic-client/ant.jpg")), + new TextContent("What is in this image?") + ]), + ] + ); + + var result = await _client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Content.Should().NotBeNullOrEmpty(); + + var text = result.Value.Content.Aggregate("", static (acc, content) => + { + if (content is TextContent textContent) + { + acc += textContent.Text; + } + + return acc; + }); + text.Should().Contain("ant"); + } + + [Fact] + public async Task CreateMessageAsync_WhenSystemMessagesContainCacheControl_ItShouldUseCache() + { var storyPath = TestFileHelper.GetTestFilePath("story.txt"); var storyText = await File.ReadAllTextAsync(storyPath); @@ -127,7 +164,7 @@ public async Task CreateMessageAsync_WhenSystemMessagesContainCacheControl_ItSho request.Messages.Add(new(MessageRole.Assistant, resultOne.Value.Content)); request.Messages.Add(new(MessageRole.User, [new TextContent("What is the main theme of this story?")])); - var resultTwo = await client.CreateMessageAsync(request); + var resultTwo = await _client.CreateMessageAsync(request); resultTwo.IsSuccess.Should().BeTrue(); resultTwo.Value.Should().BeOfType(); @@ -138,8 +175,6 @@ public async Task CreateMessageAsync_WhenSystemMessagesContainCacheControl_ItSho [Fact] public async Task CreateMessageAsync_WhenMessagesContainCacheControl_ItShouldUseCache() { - var client = CreateClient(new HttpClient()); - var storyPath = TestFileHelper.GetTestFilePath("story.txt"); var storyText = await File.ReadAllTextAsync(storyPath); @@ -153,7 +188,7 @@ public async Task CreateMessageAsync_WhenMessagesContainCacheControl_ItShouldUse ] ); - var resultOne = await client.CreateMessageAsync(request); + var resultOne = await _client.CreateMessageAsync(request); resultOne.IsSuccess.Should().BeTrue(); resultOne.Value.Should().BeOfType(); @@ -163,7 +198,7 @@ public async Task CreateMessageAsync_WhenMessagesContainCacheControl_ItShouldUse request.Messages.Add(new(MessageRole.Assistant, resultOne.Value.Content)); request.Messages.Add(new(MessageRole.User, [new TextContent("What is the main theme of this story?")])); - var resultTwo = await client.CreateMessageAsync(request); + var resultTwo = await _client.CreateMessageAsync(request); resultTwo.IsSuccess.Should().BeTrue(); resultTwo.Value.Should().BeOfType(); @@ -174,8 +209,6 @@ public async Task CreateMessageAsync_WhenMessagesContainCacheControl_ItShouldUse [Fact] public async Task CreateMessageAsync_WhenToolsContainCacheControl_ItShouldUseCache() { - var client = CreateClient(new HttpClient()); - var func = (string ticker) => ticker; var tools = Enumerable @@ -195,7 +228,7 @@ public async Task CreateMessageAsync_WhenToolsContainCacheControl_ItShouldUseCac tools: tools ); - var resultOne = await client.CreateMessageAsync(request); + var resultOne = await _client.CreateMessageAsync(request); resultOne.IsSuccess.Should().BeTrue(); resultOne.Value.Should().BeOfType(); @@ -205,7 +238,7 @@ public async Task CreateMessageAsync_WhenToolsContainCacheControl_ItShouldUseCac request.Messages.Add(new(MessageRole.Assistant, resultOne.Value.Content)); request.Messages.Add(new(MessageRole.User, [new TextContent("Could you tell me the stock price for AAPL?")])); - var resultTwo = await client.CreateMessageAsync(request); + var resultTwo = await _client.CreateMessageAsync(request); resultTwo.IsSuccess.Should().BeTrue(); resultTwo.Value.Should().BeOfType(); @@ -228,9 +261,7 @@ public async Task CreateMessageAsync_WhenProvidedWithPDF_ItShouldReturnResponse( ] ); - var client = CreateClient(new HttpClient()); - - var result = await client.CreateMessageAsync(request); + var result = await _client.CreateMessageAsync(request); result.IsSuccess.Should().BeTrue(); result.Value.Should().BeOfType(); @@ -256,8 +287,6 @@ public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShoul var bytes = await File.ReadAllBytesAsync(pdfPath); var base64Data = Convert.ToBase64String(bytes); - var client = CreateClient(new HttpClient()); - var request = new MessageRequest( model: AnthropicModels.Claude35Sonnet, messages: [ @@ -268,7 +297,7 @@ public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShoul ] ); - var resultOne = await client.CreateMessageAsync(request); + var resultOne = await _client.CreateMessageAsync(request); resultOne.IsSuccess.Should().BeTrue(); resultOne.Value.Should().BeOfType(); @@ -278,7 +307,7 @@ public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShoul request.Messages.Add(new(MessageRole.Assistant, resultOne.Value.Content)); request.Messages.Add(new(MessageRole.User, [new TextContent("What is the main theme of this paper?")])); - var resultTwo = await client.CreateMessageAsync(request); + var resultTwo = await _client.CreateMessageAsync(request); resultTwo.IsSuccess.Should().BeTrue(); resultTwo.Value.Should().BeOfType(); @@ -395,6 +424,58 @@ public async Task CreateMessageAsync_WhenCitationsAreEnabledForCustomDocumentSou citations.OfType().Should().NotBeEmpty(); } + [Fact] + public async Task CreateMessageAsync_WhenCitationsAreEnabledForFileSource_ItShouldReturnCitationsInResponse() + { + var fileName = "story.txt"; + var fileType = "text/plain"; + var filePath = TestFileHelper.GetTestFilePath("story.txt"); + var fileContent = await File.ReadAllBytesAsync(filePath); + var createFileRequest = new CreateFileRequest(fileContent, fileName, fileType); + + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + var createdFile = await client.CreateFileAsync(createFileRequest); + + var request = new MessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent(new FileSource(createdFile.Value.Id)) + { + Title = "A Story", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("Can you tell me what the title of this story is?"), + ] + ) + ] + ); + + var result = await client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + + var textContents = result.Value.Content.OfType(); + + var messageContent = textContents.Aggregate(new StringBuilder(), (sb, content) => + { + sb.Append(content.Text); + return sb; + }); + messageContent.ToString().Should().MatchRegex("The Forgotten Lighthouse"); + + var citations = textContents.SelectMany(static c => c.Citations is null ? [] : c.Citations); + citations.OfType().Should().NotBeEmpty(); + + _filesToDelete.Add(createdFile.Value.Id); + } + [Fact] public async Task CreateMessageAsync_WhenStreamingAndCitationsAreEnabledForTextDocumentSource_ItShouldReturnCitationsInResponse() { @@ -516,6 +597,64 @@ public async Task CreateMessageAsync_WhenStreamingAndCitationsAreEnabledForCusto citations.OfType().Should().NotBeEmpty(); } + [Fact] + public async Task CreateMessageAsync_WhenStreamingAndCitationsAreEnabledForFileSource_ItShouldReturnCitationsInResponse() + { + var fileName = "story.txt"; + var fileType = "text/plain"; + var filePath = TestFileHelper.GetTestFilePath("story.txt"); + var fileContent = await File.ReadAllBytesAsync(filePath); + var createFileRequest = new CreateFileRequest(fileContent, fileName, fileType); + + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + var createdFile = await client.CreateFileAsync(createFileRequest); + + var request = new StreamMessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent(new FileSource(createdFile.Value.Id)) + { + Title = "A Story", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("Can you tell me what the title of this story is?"), + ] + ) + ] + ); + + var result = client.CreateMessageAsync(request); + + var messageCompleteEvent = await result + .Where(e => e.Type is EventType.MessageComplete) + .FirstAsync(); + + var textContents = messageCompleteEvent.Data + .As() + .Message + .Content + .OfType(); + + var messageContent = textContents.Aggregate(new StringBuilder(), (sb, content) => + { + sb.Append(content.Text); + return sb; + }); + messageContent.ToString().Should().MatchRegex("The Forgotten Lighthouse"); + + var citations = textContents.SelectMany(static c => c.Citations is null ? [] : c.Citations); + citations.OfType().Should().NotBeEmpty(); + + _filesToDelete.Add(createdFile.Value.Id); + } + [Fact] public async Task CountMessageTokensAsync_WhenCalled_ItShouldReturnResponse() { @@ -697,7 +836,7 @@ public async Task CreateFileAsync_WhenCalled_ItShouldReturnResponse() var fileContent = await File.ReadAllBytesAsync(filePath); var request = new CreateFileRequest(fileContent, fileName, fileType); - var httpClient = new HttpClient(); + using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); var client = CreateClient(httpClient); @@ -707,12 +846,14 @@ public async Task CreateFileAsync_WhenCalled_ItShouldReturnResponse() result.Value.Should().BeOfType(); result.Value.Name.Should().Be(fileName); result.Value.MimeType.Should().Be(fileType); + + _filesToDelete.Add(result.Value.Id); } [Fact] public async Task ListFilesAsync_WhenCalled_ItShouldReturnPageOfFiles() { - var httpClient = new HttpClient(); + using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); var client = CreateClient(httpClient); @@ -725,12 +866,14 @@ public async Task ListFilesAsync_WhenCalled_ItShouldReturnPageOfFiles() result.IsSuccess.Should().BeTrue(); result.Value.Should().BeOfType>(); result.Value.Data.Should().ContainSingle(f => f.Id == createdFile.Value.Id); + + _filesToDelete.Add(createdFile.Value.Id); } [Fact] public async Task ListAllFilesAsync_WhenCalled_ItShouldReturnAllFiles() { - var httpClient = new HttpClient(); + using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); var client = CreateClient(httpClient); @@ -741,14 +884,18 @@ public async Task ListAllFilesAsync_WhenCalled_ItShouldReturnAllFiles() var responses = await client.ListAllFilesAsync(limit: 1).ToListAsync(); responses.Should().HaveCountGreaterThan(0); - responses.Select(r => r.Value).SelectMany(p => p.Data) - .Should().ContainSingle(f => f.Id == createdFile.Value.Id); + responses.Select(r => r.Value) + .SelectMany(p => p.Data) + .Should() + .ContainSingle(f => f.Id == createdFile.Value.Id); + + _filesToDelete.Add(createdFile.Value.Id); } [Fact] public async Task GetFileInfoAsync_WhenCalled_ItShouldReturnFile() { - var httpClient = new HttpClient(); + using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); var client = CreateClient(httpClient); @@ -761,12 +908,14 @@ public async Task GetFileInfoAsync_WhenCalled_ItShouldReturnFile() result.IsSuccess.Should().BeTrue(); result.Value.Should().BeOfType(); result.Value.Id.Should().Be(createdFile.Value.Id); + + _filesToDelete.Add(createdFile.Value.Id); } [Fact] public async Task DeleteFileAsync_WhenCalled_ItShouldReturnDeleteResponse() { - var httpClient = new HttpClient(); + using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); var client = CreateClient(httpClient); @@ -780,4 +929,17 @@ public async Task DeleteFileAsync_WhenCalled_ItShouldReturnDeleteResponse() result.Value.Should().BeOfType(); result.Value.Id.Should().Be(createdFile.Value.Id); } + + public async Task DisposeAsync() + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("anthropic-beta", "files-api-2025-04-14"); + var client = CreateClient(httpClient); + + foreach (var file in _filesToDelete) + { + var result = await client.DeleteFileAsync(file); + result.IsSuccess.Should().BeTrue(); + } + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs new file mode 100644 index 0000000..6a99d28 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs @@ -0,0 +1,52 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class FileSourceTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""file_id"": ""id"", + ""type"": ""file"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var result = new FileSource(); + + result.Type.Should().Be("file"); + result.Id.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldInitializeProperties() + { + var id = "id"; + var type = "type"; + + var result = new FileSource() + { + Id = id, + Type = type, + }; + + result.Id.Should().Be(id); + result.Type.Should().Be(type); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var source = new FileSource() { Id = "id" }; + + var result = Serialize(source); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedValues() + { + var result = Deserialize(_testJson); + + result.Should().BeEquivalentTo(new FileSource() { Id = "id" }); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs index bcc4e50..c75db27 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs @@ -77,7 +77,7 @@ public void Constructor_WhenCalledWithCacheControl_ItShouldInitializeSourceAndCa } [Fact] - public void Constructor_WhenCalledWithCacheControlAndMediatTypeIsNull_ItShouldThrowArgumentNullException() + public void Constructor_WhenCalledWithCacheControlAndMediaTypeIsNull_ItShouldThrowArgumentNullException() { var expectedData = "data"; var cacheControl = new EphemeralCacheControl(); @@ -98,6 +98,49 @@ public void Constructor_WhenCalledWithCacheControlAndDataIsNull_ItShouldThrowArg action.Should().Throw(); } + [Fact] + public void Constructor_WhenCalledWithSource_ItShouldInitializeSource() + { + var source = new ImageSource("image/png", "data"); + + var result = new ImageContent(source); + + result.Source.Should().BeSameAs(source); + } + + [Fact] + public void Constructor_WhenCalledWithSourceAndCacheControl_ItShouldInitializeSourceAndCacheControl() + { + var source = new ImageSource("image/png", "data"); + var cacheControl = new EphemeralCacheControl(); + + var result = new ImageContent(source, cacheControl); + + result.Source.Should().BeSameAs(source); + result.CacheControl.Should().BeSameAs(cacheControl); + } + + [Fact] + public void Constructor_WhenSourceIsNull_ItShouldThrowArgumentNullException() + { + Source? source = null; + + var action = () => new ImageContent(source!); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCacheControlIsNull_ItShouldThrowNullException() + { + var source = new ImageSource("image/png", "data"); + CacheControl? cacheControl = null; + + var action = () => new ImageContent(source, cacheControl!); + + action.Should().Throw(); + } + [Fact] public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() { diff --git a/tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs index 1e091ab..561dbf8 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs @@ -19,4 +19,10 @@ public void Text_WhenCalled_ItShouldReturnCorrectValue() { SourceType.Text.Should().Be("text"); } + + [Fact] + public void File_WhenCalled_ItShouldReturnCorrectValue() + { + SourceType.File.Should().Be("file"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs new file mode 100644 index 0000000..d1ccbc8 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs @@ -0,0 +1,64 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class UrlSourceTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""url"", + ""url"": ""https://example.com/document.pdf"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var result = new UrlSource(); + + result.Url.Should().BeEmpty(); + result.Type.Should().Be("url"); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldInitializeProperties() + { + var url = "https://example.com/document.pdf"; + var type = "type"; + + var result = new UrlSource() + { + Url = url, + Type = type, + }; + + result.Url.Should().Be(url); + result.Type.Should().Be(type); + } + + [Fact] + public void Constructor_WhenCalledWithUrl_ItShouldInitializeProperties() + { + var url = "https://example.com/document.pdf"; + + var result = new UrlSource(url); + + result.Url.Should().Be(url); + result.Type.Should().Be("url"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var url = "https://example.com/document.pdf"; + var source = new UrlSource(url); + + var result = Serialize(source); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldMatchExpectedObject() + { + var result = Deserialize(_testJson); + + result.Should().BeEquivalentTo(new UrlSource("https://example.com/document.pdf")); + } +} From 3c03e704aa4d2c0b98477215349c3f786cfbd625 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:03:42 -0500 Subject: [PATCH 19/21] tests: add test for file source constructor with id --- .../Unit/Models/FileSourceTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs index 6a99d28..621b585 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/FileSourceTests.cs @@ -32,6 +32,17 @@ public void Constructor_WhenCalledWithValues_ItShouldInitializeProperties() result.Type.Should().Be(type); } + [Fact] + public void Constructor_WhenCalledWithId_ItShouldInitializeProperties() + { + var id = "id"; + + var result = new FileSource(id); + + result.Id.Should().Be(id); + result.Type.Should().Be("file"); + } + [Fact] public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() { From 3dd6d6c9d7adb90bf3c46767acdf25e2092b24d0 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:06:15 -0500 Subject: [PATCH 20/21] chore: run dotnet format --- tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs index d1ccbc8..d9f88cc 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/UrlSourceTests.cs @@ -61,4 +61,4 @@ public void JsonDeserialization_WhenDeserialized_ItShouldMatchExpectedObject() result.Should().BeEquivalentTo(new UrlSource("https://example.com/document.pdf")); } -} +} \ No newline at end of file From 8d2b023f89a7ac4175afbc40bc07d8e4fdd33f04 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:20:02 -0500 Subject: [PATCH 21/21] feat: add missing model constants --- src/AnthropicClient/Models/AnthropicModels.cs | 30 ++++++++++ .../Unit/Models/AnthropicModelsTests.cs | 60 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/AnthropicClient/Models/AnthropicModels.cs b/src/AnthropicClient/Models/AnthropicModels.cs index 158a5a9..c9b7bb0 100644 --- a/src/AnthropicClient/Models/AnthropicModels.cs +++ b/src/AnthropicClient/Models/AnthropicModels.cs @@ -20,6 +20,16 @@ public static class AnthropicModels /// public const string Claude3OpusLatest = "claude-3-opus-latest"; + /// + /// The Claude 4 Opus model. + /// + public const string ClaudeOpus420250514 = "claude-opus-4-20250514"; + + /// + /// The Claude 4 Opus model. + /// + public const string ClaudeOpus40 = "claude-opus-4-0"; + /// /// The Claude 3 Sonnet model. /// @@ -50,6 +60,26 @@ public static class AnthropicModels /// public const string Claude35SonnetLatest = "claude-3-5-sonnet-latest"; + /// + /// The Claude 3 Sonnet model + /// + public const string Claude37Sonnet20250219 = "claude-3-7-sonnet-20250219"; + + /// + /// The Claude 3 Sonnet model + /// + public const string Claude37SonnetLatest = "claude-3-7-sonnet-latest"; + + /// + /// The Claude 4 Sonnet model. + /// + public const string ClaudeSonnet420250514 = "claude-sonnet-4-20250514"; + + /// + /// The Claude 4 Sonnet model. + /// + public const string ClaudeSonnet40 = "claude-sonnet-4-0"; + /// /// The Claude 3 Haiku model. /// diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs index 3b2343a..d9eac11 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs @@ -131,4 +131,64 @@ public void Claude35HaikuLatest_WhenCalled_ItShouldReturnExpectedValue() actual.Should().Be(expected); } + + [Fact] + public void Claude37Sonnet20250219_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-3-7-sonnet-20250219"; + + var actual = AnthropicModels.Claude37Sonnet20250219; + + actual.Should().Be(expected); + } + + [Fact] + public void Claude37SonnetLatest_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-3-7-sonnet-latest"; + + var actual = AnthropicModels.Claude37SonnetLatest; + + actual.Should().Be(expected); + } + + [Fact] + public void ClaudeSonnet420250514_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-sonnet-4-20250514"; + + var actual = AnthropicModels.ClaudeSonnet420250514; + + actual.Should().Be(expected); + } + + [Fact] + public void ClaudeSonnet40_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-sonnet-4-0"; + + var actual = AnthropicModels.ClaudeSonnet40; + + actual.Should().Be(expected); + } + + [Fact] + public void ClaudeOpus420250514_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-opus-4-20250514"; + + var actual = AnthropicModels.ClaudeOpus420250514; + + actual.Should().Be(expected); + } + + [Fact] + public void ClaudeOpus40_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-opus-4-0"; + + var actual = AnthropicModels.ClaudeOpus40; + + actual.Should().Be(expected); + } } \ No newline at end of file