From 9548754d6cab73d21c7b7a28264374e79dcb8bab Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:37:58 -0600 Subject: [PATCH 01/22] feat: implement CreateMessageBatchAsync method --- src/AnthropicClient/AnthropicApiClient.cs | 27 +++- .../Models/MessageBatchRequest.cs | 28 +++++ .../Models/MessageBatchRequestItem.cs | 39 ++++++ .../Models/MessageBatchResponse.cs | 98 +++++++++++++++ .../Integration/AnthropicApiClientTests.cs | 54 ++++++++ .../Integration/IntegrationTest.cs | 7 ++ .../Models/MessageBatchRequestItemTests.cs | 36 ++++++ .../Unit/Models/MessageBatchRequestTests.cs | 84 +++++++++++++ .../Unit/Models/MessageBatchResponseTests.cs | 115 ++++++++++++++++++ 9 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/AnthropicClient/Models/MessageBatchRequest.cs create mode 100644 src/AnthropicClient/Models/MessageBatchRequestItem.cs create mode 100644 src/AnthropicClient/Models/MessageBatchResponse.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 3be31ee..831161d 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -28,6 +28,13 @@ public interface IAnthropicApiClient /// An asynchronous enumerable that yields the response event by event. IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request); + /// + /// Creates a batch of messages asynchronously. + /// + /// The message batch request to create. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> CreateMessageBatchAsync(MessageBatchRequest request); + /// /// Counts the tokens in a message asynchronously. /// @@ -64,7 +71,8 @@ public class AnthropicApiClient : IAnthropicApiClient private const string BaseUrl = "https://api.anthropic.com/v1/"; private const string ApiKeyHeader = "x-api-key"; private const string MessagesEndpoint = "messages"; - private const string CountTokensEndpoint = "messages/count_tokens"; + private string CountTokensEndpoint => $"{MessagesEndpoint}/count-tokens"; + private string MessageBatchesEndpoint => $"{MessagesEndpoint}/batches"; private const string ModelsEndpoint = "models"; private const string JsonContentType = "application/json"; private const string EventPrefix = "event:"; @@ -287,6 +295,23 @@ public class AnthropicApiClient : IAnthropicApiClient } while (true); } + /// + public async Task> CreateMessageBatchAsync(MessageBatchRequest request) + { + var response = await SendRequestAsync(MessageBatchesEndpoint, request); + var anthropicHeaders = new AnthropicHeaders(response.Headers); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + var error = Deserialize(responseContent) ?? new AnthropicError(); + return AnthropicResult.Failure(error, anthropicHeaders); + } + + var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse(); + return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); + } + /// public async Task> CountMessageTokensAsync(CountMessageTokensRequest request) { diff --git a/src/AnthropicClient/Models/MessageBatchRequest.cs b/src/AnthropicClient/Models/MessageBatchRequest.cs new file mode 100644 index 0000000..0af5514 --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchRequest.cs @@ -0,0 +1,28 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a request to create a batch of messages. +/// +public class MessageBatchRequest +{ + /// + /// Gets the requests to create messages. + /// + public List Requests { get; init; } = []; + + /// + /// Initializes a new instance of the class. + /// + /// The requests to create messages. + /// Thrown when is empty. + /// An instance of the class. + public MessageBatchRequest(List requests) + { + if (requests.Count == 0) + { + throw new ArgumentException($"{nameof(requests)} must not be empty.", nameof(requests)); + } + + Requests = requests; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageBatchRequestItem.cs b/src/AnthropicClient/Models/MessageBatchRequestItem.cs new file mode 100644 index 0000000..aec4aee --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchRequestItem.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents an item in a batch of messages. +/// +public class MessageBatchRequestItem +{ + /// + /// Gets the custom identifier for the message. + /// + [JsonPropertyName("custom_id")] + public string CustomId { get; init; } + + /// + /// Gets the message request parameters. + /// + public MessageRequest Params { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// The custom identifier for the message. + /// The message request parameters. + /// Thrown when is null or whitespace. + /// Thrown when is null. + /// An instance of the class. + public MessageBatchRequestItem(string customId, MessageRequest messageRequest) + { + ArgumentValidator.ThrowIfNullOrWhitespace(customId, nameof(customId)); + ArgumentValidator.ThrowIfNull(messageRequest, nameof(messageRequest)); + + CustomId = customId; + Params = messageRequest; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageBatchResponse.cs b/src/AnthropicClient/Models/MessageBatchResponse.cs new file mode 100644 index 0000000..b3c0a0e --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchResponse.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a response to a batch of messages. +/// +public class MessageBatchResponse +{ + /// + /// Gets the identifier of the batch. + /// + public string Id { get; init; } = string.Empty; + + /// + /// Gets the type of the batch. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Gets the processing status of the batch. + /// + [JsonPropertyName("processing_status")] + public string ProcessingStatus { get; init; } = string.Empty; + + /// + /// Gets the counts of requests in the batch. + /// + [JsonPropertyName("request_counts")] + public MessageBatchRequestCounts RequestCounts { get; init; } = new MessageBatchRequestCounts(); + + /// + /// Gets the date and time when the batch ended. + /// + [JsonPropertyName("ended_at")] + public DateTimeOffset EndedAt { get; init; } + + /// + /// Gets the date and time when the batch was created. + /// + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; init; } + + /// + /// Gets the date and time when the batch expires. + /// + [JsonPropertyName("expires_at")] + public DateTimeOffset ExpiresAt { get; init; } + + /// + /// Gets the date and time when the batch was archived. + /// + [JsonPropertyName("archived_at")] + public DateTimeOffset ArchivedAt { get; init; } + + /// + /// Gets the date and time when the batch cancellation was initiated. + /// + [JsonPropertyName("cancel_initiated_at")] + public DateTimeOffset CancelInitiatedAt { get; init; } + + /// + /// Gets the URL to the results of the batch. + /// + [JsonPropertyName("results_url")] + public string ResultsUrl { get; init; } = string.Empty; +} + +/// +/// Represents the counts of requests in a batch of messages. +/// +public class MessageBatchRequestCounts +{ + /// + /// Gets the number of requests in the batch that are processing. + /// + public int Processing { get; init; } + + /// + /// Gets the number of requests in the batch that succeeded. + /// + public int Succeeded { get; init; } + + /// + /// Gets the number of requests in the batch that errored. + /// + public int Errored { get; init; } + + /// + /// Gets the number of requests in the batch that were cancelled. + /// + public int Canceled { get; init; } + + /// + /// Gets the number of requests in the batch that expired. + /// + public int Expired { get; init; } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index eaef353..ca97570 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -978,4 +978,58 @@ public class AnthropicApiClientTests : IntegrationTest result.IsSuccess.Should().BeTrue(); result.Value.Should().BeOfType(); } + + [Fact] + public async Task CreateMessageBatchAsync_WhenCalled_ItShouldReturnBatch() + { + _mockHttpMessageHandler + .WhenCreateMessageBatchRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + }" + ); + + var request = new MessageBatchRequest([new("custom_id", new())]); + + var result = await Client.CreateMessageBatchAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be("msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"); + result.Value.Type.Should().Be("message_batch"); + result.Value.ProcessingStatus.Should().Be("in_progress"); + result.Value.RequestCounts.Should().BeEquivalentTo(new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }); + + result.Value.EndedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.CreatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.ExpiresAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.ArchivedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.CancelInitiatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.ResultsUrl.Should().Be("https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index 6b6376d..1a9ef1c 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -16,6 +16,7 @@ public static class MockHttpMessageHandlerExtensions private const string BaseUrl = "https://api.anthropic.com/v1"; private static readonly string MessagesEndpoint = $"{BaseUrl}/messages"; 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 MockedRequest SetupBaseRequest( @@ -64,4 +65,10 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Get, $"{ModelsEndpoint}/{modelId}"); } + + public static MockedRequest WhenCreateMessageBatchRequest(this MockHttpMessageHandler mockHttpMessageHandler) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Post, MessageBatchesEndpoint); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs new file mode 100644 index 0000000..2f14087 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs @@ -0,0 +1,36 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchRequestItemTests : SerializationTest +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstanceWithPropertiesSet() + { + var customId = "custom_id"; + var messageRequest = new MessageRequest(); + + var result = new MessageBatchRequestItem(customId, messageRequest); + + result.Should().BeOfType(); + result.CustomId.Should().Be(customId); + result.Params.Should().BeSameAs(messageRequest); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Constructor_WhenCalledAndCustomIdIsInvalid_ItShouldThrowException(string? customId) + { + var act = () => new MessageBatchRequestItem(customId!, new MessageRequest()); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndMessageRequestIsNull_ItShouldThrowException() + { + var act = () => new MessageBatchRequestItem("custom_id", null!); + + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs new file mode 100644 index 0000000..ccd2691 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs @@ -0,0 +1,84 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchRequestTests : SerializationTest +{ + private const string SampleJson = @"{ + ""requests"": [ + { + ""custom_id"": ""my-first-request"", + ""params"": { + ""model"": ""claude-3-5-sonnet-20241022"", + ""messages"": [ + {""role"": ""user"", ""content"": [{ ""text"": ""Hello, world"", ""type"": ""text"" }]} + ], + ""max_tokens"": 1024, + ""stop_sequences"": [], + ""temperature"": 0.0, + ""stream"": false + } + }, + { + ""custom_id"": ""my-second-request"", + ""params"": { + ""model"": ""claude-3-5-sonnet-20241022"", + ""messages"": [ + {""role"": ""user"", ""content"": [{ ""text"": ""Hi again, friend"", ""type"": ""text"" }]} + ], + ""max_tokens"": 1024, + ""stop_sequences"": [], + ""temperature"": 0.0, + ""stream"": false + } + } + ] + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstanceWithPropertiesSet() + { + var requests = new List { new("custom_id", new()) }; + + var result = new MessageBatchRequest(requests); + + result.Should().BeOfType(); + result.Requests.Should().BeSameAs(requests); + } + + [Fact] + public void Constructor_WhenCalledWithEmptyRequests_ItShouldThrowException() + { + var act = () => new MessageBatchRequest([]); + + act.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var requests = new List + { + new("my-first-request", new() + { + Model = "claude-3-5-sonnet-20241022", + MaxTokens = 1024, + Messages = [ + new() { Role = "user", Content = [new TextContent("Hello, world")] } + ] + }), + new("my-second-request", new() + { + Model = "claude-3-5-sonnet-20241022", + MaxTokens = 1024, + Messages = [ + new() { Role = "user", Content = [new TextContent("Hi again, friend")] } + ] + }) + }; + + var result = new MessageBatchRequest(requests); + + var json = Serialize(result); + + JsonAssert.Equal(SampleJson, json); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs new file mode 100644 index 0000000..4cde6a1 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs @@ -0,0 +1,115 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchResponseTests : SerializationTest +{ + private const string SampleJson = @"{ + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + }"; + + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstanceWithPropertiesSet() + { + var result = new MessageBatchResponse(); + + result.Should().BeOfType(); + result.Id.Should().BeEmpty(); + result.Type.Should().BeEmpty(); + result.ProcessingStatus.Should().BeEmpty(); + result.RequestCounts.Should().BeEquivalentTo(new MessageBatchRequestCounts()); + result.EndedAt.Should().Be(DateTimeOffset.MinValue); + result.CreatedAt.Should().Be(DateTimeOffset.MinValue); + result.ExpiresAt.Should().Be(DateTimeOffset.MinValue); + result.ArchivedAt.Should().Be(DateTimeOffset.MinValue); + result.CancelInitiatedAt.Should().Be(DateTimeOffset.MinValue); + result.ResultsUrl.Should().BeEmpty(); + } + + [Fact] + public void JsonSerialization_WhenDeserialized_ItShouldHaveExpectedValues() + { + var result = Deserialize(SampleJson); + + result.Should().BeEquivalentTo(new MessageBatchResponse + { + Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + Type = "message_batch", + ProcessingStatus = "in_progress", + RequestCounts = new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }, + EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results" + }); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var expectedJson = @"{ + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435+00:00"", + ""created_at"": ""2024-08-20T18:37:24.100435+00:00"", + ""expires_at"": ""2024-08-20T18:37:24.100435+00:00"", + ""archived_at"": ""2024-08-20T18:37:24.100435+00:00"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435+00:00"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + }"; + + var result = Serialize(new MessageBatchResponse + { + Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + Type = "message_batch", + ProcessingStatus = "in_progress", + RequestCounts = new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }, + EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results" + }); + + JsonAssert.Equal(expectedJson, result); + } +} \ No newline at end of file From 5e247a3c3afb9a3dad954eaa5890de106ae2edb9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:39:16 -0600 Subject: [PATCH 02/22] fix: use proper count tokens endpoint --- src/AnthropicClient/AnthropicApiClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 831161d..1cc6ebb 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -71,7 +71,7 @@ public class AnthropicApiClient : IAnthropicApiClient private const string BaseUrl = "https://api.anthropic.com/v1/"; private const string ApiKeyHeader = "x-api-key"; private const string MessagesEndpoint = "messages"; - private string CountTokensEndpoint => $"{MessagesEndpoint}/count-tokens"; + private string CountTokensEndpoint => $"{MessagesEndpoint}/count_tokens"; private string MessageBatchesEndpoint => $"{MessagesEndpoint}/batches"; private const string ModelsEndpoint = "models"; private const string JsonContentType = "application/json"; From 15f5ad49e7f41215c3b089cdfe3873182223ce2d Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:32:36 -0600 Subject: [PATCH 03/22] fix: make correct properties nullable --- src/AnthropicClient/Models/MessageBatchResponse.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AnthropicClient/Models/MessageBatchResponse.cs b/src/AnthropicClient/Models/MessageBatchResponse.cs index b3c0a0e..925e713 100644 --- a/src/AnthropicClient/Models/MessageBatchResponse.cs +++ b/src/AnthropicClient/Models/MessageBatchResponse.cs @@ -33,7 +33,7 @@ public class MessageBatchResponse /// Gets the date and time when the batch ended. /// [JsonPropertyName("ended_at")] - public DateTimeOffset EndedAt { get; init; } + public DateTimeOffset? EndedAt { get; init; } /// /// Gets the date and time when the batch was created. @@ -51,19 +51,19 @@ public class MessageBatchResponse /// Gets the date and time when the batch was archived. /// [JsonPropertyName("archived_at")] - public DateTimeOffset ArchivedAt { get; init; } + public DateTimeOffset? ArchivedAt { get; init; } /// /// Gets the date and time when the batch cancellation was initiated. /// [JsonPropertyName("cancel_initiated_at")] - public DateTimeOffset CancelInitiatedAt { get; init; } + public DateTimeOffset? CancelInitiatedAt { get; init; } /// /// Gets the URL to the results of the batch. /// [JsonPropertyName("results_url")] - public string ResultsUrl { get; init; } = string.Empty; + public string? ResultsUrl { get; init; } = string.Empty; } /// From 854e4642abdb56af91484111ea0f36398f5e37c1 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:33:09 -0600 Subject: [PATCH 04/22] =?UTF-8?q?tests:=20add=20integration=20tests=20for?= =?UTF-8?q?=20remaining=20=F0=9F=A5=B2=20and=20=F0=9F=98=80=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Integration/AnthropicApiClientTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index ca97570..ba2a6a2 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1032,4 +1032,70 @@ public class AnthropicApiClientTests : IntegrationTest result.Value.CancelInitiatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); result.Value.ResultsUrl.Should().Be("https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"); } + + [Fact] + public async Task CreateMessageBatchAsync_WhenCalledAndErrorReturned_ItShouldHandleError() + { + _mockHttpMessageHandler + .WhenCreateMessageBatchRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row"" + } + }" + ); + + var request = new MessageBatchRequest([new("custom_id", new())]); + + var result = await Client.CreateMessageBatchAsync(request); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task CreateMessageBatchAsync_WhenCalledRequestFailsAndErrorCanNotBeDeserialized_ItShouldReturnUnknownError() + { + _mockHttpMessageHandler + .WhenCreateMessageBatchRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{}" + ); + + var request = new MessageBatchRequest([new("custom_id", new())]); + + var result = await Client.CreateMessageBatchAsync(request); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task CreateMessageBatchAsync_WhenCalledRequestSucceedsAndCanNotDeserializeResponse_ItShouldReturnEmptyResponse() + { + _mockHttpMessageHandler + .WhenCreateMessageBatchRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{}" + ); + + var request = new MessageBatchRequest([new("custom_id", new())]); + + var result = await Client.CreateMessageBatchAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Should().BeEquivalentTo(new MessageBatchResponse()); + } } \ No newline at end of file From 779a5ca2818e79081cbd9a8fab4a7cbb7b4e0222 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:33:27 -0600 Subject: [PATCH 05/22] tests: add end-to-end test for creating a message batch --- .../EndToEnd/AnthropicApiClientTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 47ccadd..2fff56f 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -341,4 +341,24 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf result.Value.Should().BeOfType(); result.Value.Id.Should().Be(AnthropicModels.Claude3Haiku); } + + [Fact] + public async Task CreateMessageBatchAsync_WhenCalled_ItShouldReturnResponse() + { + var request = new MessageBatchRequest([ + new( + Guid.NewGuid().ToString(), + new( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ) + ), + ]); + + var result = await _client.CreateMessageBatchAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().NotBeNullOrEmpty(); + } } \ No newline at end of file From f8e01bf4878e07f823d524408ed59a2232960a7b Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:46:17 -0600 Subject: [PATCH 06/22] feat: add class for representing batch statuses --- .../Models/MessageBatchStatus.cs | 22 +++++++++++++++++++ .../Unit/Models/MessageBatchStatusTests.cs | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/AnthropicClient/Models/MessageBatchStatus.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs diff --git a/src/AnthropicClient/Models/MessageBatchStatus.cs b/src/AnthropicClient/Models/MessageBatchStatus.cs new file mode 100644 index 0000000..5e1f299 --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchStatus.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the status of a message batch. +/// +public static class MessageBatchStatus +{ + /// + /// The status of a message batch that is being canceled. + /// + public const string Canceling = "canceling"; + + /// + /// The status of a message batch that is in progress. + /// + public const string InProgress = "in_progress"; + + /// + /// The status of a message batch that has ended. + /// + public const string Ended = "ended"; +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs new file mode 100644 index 0000000..1d66d45 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchStatusTests +{ + [Fact] + public void Canceling_WhenCalled_ItShouldReturnCancelingStatus() + { + MessageBatchStatus.Canceling.Should().Be("canceling"); + } + + [Fact] + public void InProgress_WhenCalled_ItShouldReturnCancelingStatus() + { + MessageBatchStatus.InProgress.Should().Be("in_progress"); + } + + [Fact] + public void Ended_WhenCalled_ItShouldReturnCancelingStatus() + { + MessageBatchStatus.Ended.Should().Be("ended"); + } +} \ No newline at end of file From c5f3c5eca549a89ac6b8197b833767c0ca4465c5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 23:15:24 -0600 Subject: [PATCH 07/22] feat: implement GetMessageBatchAsync method --- src/AnthropicClient/AnthropicApiClient.cs | 26 +++- .../EndToEnd/AnthropicApiClientTests.cs | 21 +++ .../Integration/AnthropicApiClientTests.cs | 120 ++++++++++++++++++ .../Integration/IntegrationTest.cs | 6 + 4 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 1cc6ebb..9d97391 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -34,7 +34,14 @@ public interface IAnthropicApiClient /// The message batch request to create. /// A task that represents the asynchronous operation. The task result contains the response as an where T is . Task> CreateMessageBatchAsync(MessageBatchRequest request); - + + /// + /// Gets a message batch asynchronously. + /// + /// The ID of the message batch to get. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> GetMessageBatchAsync(string batchId); + /// /// Counts the tokens in a message asynchronously. /// @@ -312,6 +319,23 @@ public class AnthropicApiClient : IAnthropicApiClient return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); } + /// + public async Task> GetMessageBatchAsync(string batchId) + { + var response = await SendRequestAsync($"{MessageBatchesEndpoint}/{batchId}"); + var anthropicHeaders = new AnthropicHeaders(response.Headers); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + var error = Deserialize(responseContent) ?? new AnthropicError(); + return AnthropicResult.Failure(error, anthropicHeaders); + } + + var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse(); + return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); + } + /// public async Task> CountMessageTokensAsync(CountMessageTokensRequest request) { diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 2fff56f..4909936 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -361,4 +361,25 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf result.Value.Should().BeOfType(); result.Value.Id.Should().NotBeNullOrEmpty(); } + + [Fact] + public async Task GetMessageBatchAsync_WhenCalled_ItShouldReturnResponse() + { + var request = new MessageBatchRequest([ + new( + Guid.NewGuid().ToString(), + new( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ) + ), + ]); + + var createResult = await _client.CreateMessageBatchAsync(request); + var getResult = await _client.GetMessageBatchAsync(createResult.Value.Id); + + getResult.IsSuccess.Should().BeTrue(); + getResult.Value.Should().BeOfType(); + getResult.Value.Id.Should().Be(createResult.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 ba2a6a2..d450495 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1098,4 +1098,124 @@ public class AnthropicApiClientTests : IntegrationTest result.Value.Should().BeOfType(); result.Value.Should().BeEquivalentTo(new MessageBatchResponse()); } + + [Fact] + public async Task GetMessageBatchAsync_WhenCalled_ItShouldReturnBatch() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetMessageBatchRequest(batchId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + }" + ); + + var result = await Client.GetMessageBatchAsync(batchId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be("msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"); + result.Value.Type.Should().Be("message_batch"); + result.Value.ProcessingStatus.Should().Be("in_progress"); + result.Value.RequestCounts.Should().BeEquivalentTo(new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }); + + result.Value.EndedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.CreatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.ExpiresAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.ArchivedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.CancelInitiatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); + result.Value.ResultsUrl.Should() + .Be("https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"); + } + + [Fact] + public async Task GetMessageBatchAsync_WhenCalledAndErrorReturned_ItShouldHandleError() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetMessageBatchRequest(batchId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row"" + } + }" + ); + + var result = await Client.GetMessageBatchAsync(batchId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task GetMessageBatchAsync_WhenCalledRequestFailsAndErrorCanNotBeDeserialized_ItShouldReturnUnknownError() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetMessageBatchRequest(batchId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{}" + ); + + var result = await Client.GetMessageBatchAsync(batchId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task GetMessageBatchAsync_WhenCalledAndCanNotDeserializeResponse_ItShouldReturnEmptyResponse() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetMessageBatchRequest(batchId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{}" + ); + + var result = await Client.GetMessageBatchAsync(batchId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index 1a9ef1c..b69a60d 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -71,4 +71,10 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Post, MessageBatchesEndpoint); } + + public static MockedRequest WhenGetMessageBatchRequest(this MockHttpMessageHandler mockHttpMessageHandler, string batchId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, $"{MessageBatchesEndpoint}/{batchId}"); + } } \ No newline at end of file From 0a6d89bd77e165a0ae63dacd9f854bd49ba817ce Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 8 Jan 2025 23:20:21 -0600 Subject: [PATCH 08/22] fix: default nullable properties to actually datetimeoffset min value when new'd up --- src/AnthropicClient/Models/MessageBatchResponse.cs | 8 ++++---- .../Unit/Models/MessageBatchResponseTests.cs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/AnthropicClient/Models/MessageBatchResponse.cs b/src/AnthropicClient/Models/MessageBatchResponse.cs index 925e713..2df3b43 100644 --- a/src/AnthropicClient/Models/MessageBatchResponse.cs +++ b/src/AnthropicClient/Models/MessageBatchResponse.cs @@ -27,13 +27,13 @@ public class MessageBatchResponse /// Gets the counts of requests in the batch. /// [JsonPropertyName("request_counts")] - public MessageBatchRequestCounts RequestCounts { get; init; } = new MessageBatchRequestCounts(); + public MessageBatchRequestCounts RequestCounts { get; init; } = new(); /// /// Gets the date and time when the batch ended. /// [JsonPropertyName("ended_at")] - public DateTimeOffset? EndedAt { get; init; } + public DateTimeOffset? EndedAt { get; init; } = DateTimeOffset.MinValue; /// /// Gets the date and time when the batch was created. @@ -51,13 +51,13 @@ public class MessageBatchResponse /// Gets the date and time when the batch was archived. /// [JsonPropertyName("archived_at")] - public DateTimeOffset? ArchivedAt { get; init; } + public DateTimeOffset? ArchivedAt { get; init; } = DateTimeOffset.MinValue; /// /// Gets the date and time when the batch cancellation was initiated. /// [JsonPropertyName("cancel_initiated_at")] - public DateTimeOffset? CancelInitiatedAt { get; init; } + public DateTimeOffset? CancelInitiatedAt { get; init; } = DateTimeOffset.MinValue; /// /// Gets the URL to the results of the batch. diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs index 4cde6a1..5001b4d 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs @@ -50,7 +50,7 @@ public class MessageBatchResponseTests : SerializationTest Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", Type = "message_batch", ProcessingStatus = "in_progress", - RequestCounts = new MessageBatchRequestCounts + RequestCounts = new() { Processing = 100, Succeeded = 50, @@ -94,7 +94,7 @@ public class MessageBatchResponseTests : SerializationTest Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", Type = "message_batch", ProcessingStatus = "in_progress", - RequestCounts = new MessageBatchRequestCounts + RequestCounts = new() { Processing = 100, Succeeded = 50, From c56adf394c62aca10c89f1d8650635cefe248d50 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 9 Jan 2025 23:17:41 -0600 Subject: [PATCH 09/22] feat: implement GetMessageBatchResultsAsync method --- src/AnthropicClient/AnthropicApiClient.cs | 47 +++++++++++++++++-- .../Json/JsonSerializationOptions.cs | 1 + .../Json/MessageBatchResultConverter.cs | 29 ++++++++++++ .../Models/CanceledMessageBatchResult.cs | 14 ++++++ .../Models/ErroredMessageBatchResult.cs | 19 ++++++++ .../Models/ExpiredMessageBatchResult.cs | 14 ++++++ .../Models/MessageBatchResult.cs | 26 ++++++++++ .../Models/MessageBatchResultItem.cs | 20 ++++++++ .../Models/MessageBatchResultType.cs | 27 +++++++++++ .../Models/SucceededMessageBatchResult.cs | 20 ++++++++ .../EndToEnd/AnthropicApiClientTests.cs | 21 ++++----- .../Files/TestFileHelper.cs | 7 +++ .../Files/batch_results.jsonl | 5 ++ .../Integration/AnthropicApiClientTests.cs | 41 +++++++++++++--- .../Integration/IntegrationTest.cs | 12 ++++- .../Models/MessageBatchResultItemTests.cs | 14 ++++++ 16 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 src/AnthropicClient/Json/MessageBatchResultConverter.cs create mode 100644 src/AnthropicClient/Models/CanceledMessageBatchResult.cs create mode 100644 src/AnthropicClient/Models/ErroredMessageBatchResult.cs create mode 100644 src/AnthropicClient/Models/ExpiredMessageBatchResult.cs create mode 100644 src/AnthropicClient/Models/MessageBatchResult.cs create mode 100644 src/AnthropicClient/Models/MessageBatchResultItem.cs create mode 100644 src/AnthropicClient/Models/MessageBatchResultType.cs create mode 100644 src/AnthropicClient/Models/SucceededMessageBatchResult.cs create mode 100644 tests/AnthropicClient.Tests/Files/TestFileHelper.cs create mode 100644 tests/AnthropicClient.Tests/Files/batch_results.jsonl create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultItemTests.cs diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 9d97391..cb5f0c5 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -34,14 +34,21 @@ public interface IAnthropicApiClient /// The message batch request to create. /// A task that represents the asynchronous operation. The task result contains the response as an where T is . Task> CreateMessageBatchAsync(MessageBatchRequest request); - + /// /// Gets a message batch asynchronously. /// /// The ID of the message batch to get. /// A task that represents the asynchronous operation. The task result contains the response as an where T is . Task> GetMessageBatchAsync(string batchId); - + + /// + /// Gets the results of a message batch asynchronously. + /// + /// The ID of the message batch to get the results for. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . + Task>> GetMessageBatchResultsAsync(string batchId); + /// /// Counts the tokens in a message asynchronously. /// @@ -325,17 +332,49 @@ public class AnthropicApiClient : IAnthropicApiClient var response = await SendRequestAsync($"{MessageBatchesEndpoint}/{batchId}"); var anthropicHeaders = new AnthropicHeaders(response.Headers); var responseContent = await response.Content.ReadAsStringAsync(); - + if (response.IsSuccessStatusCode is false) { var error = Deserialize(responseContent) ?? new AnthropicError(); return AnthropicResult.Failure(error, anthropicHeaders); } - + var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse(); return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); } + /// + public async Task>> GetMessageBatchResultsAsync(string batchId) + { + var response = await SendRequestAsync($"{MessageBatchesEndpoint}/{batchId}/results"); + var anthropicHeaders = new AnthropicHeaders(response.Headers); + + if (response.IsSuccessStatusCode is false) + { + var content = await response.Content.ReadAsStringAsync(); + var error = Deserialize(content) ?? new AnthropicError(); + return AnthropicResult>.Failure(error, anthropicHeaders); + } + + async IAsyncEnumerable ReadResults() + { + using var responseContent = await response.Content.ReadAsStreamAsync(); + using var streamReader = new StreamReader(responseContent); + + var line = await streamReader.ReadLineAsync(); + + while (line is not null) + { + var resultItem = Deserialize(line) ?? new MessageBatchResultItem(); + yield return resultItem; + + line = await streamReader.ReadLineAsync(); + } + } + + return AnthropicResult>.Success(ReadResults(), anthropicHeaders); + } + /// public async Task> CountMessageTokensAsync(CountMessageTokensRequest request) { diff --git a/src/AnthropicClient/Json/JsonSerializationOptions.cs b/src/AnthropicClient/Json/JsonSerializationOptions.cs index 6e9dece..c1d5c40 100644 --- a/src/AnthropicClient/Json/JsonSerializationOptions.cs +++ b/src/AnthropicClient/Json/JsonSerializationOptions.cs @@ -17,6 +17,7 @@ static class JsonSerializationOptions new EventDataConverter(), new ContentDeltaConverter(), new JsonStringEnumConverter(), + new MessageBatchResultConverter(), }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; diff --git a/src/AnthropicClient/Json/MessageBatchResultConverter.cs b/src/AnthropicClient/Json/MessageBatchResultConverter.cs new file mode 100644 index 0000000..1df20b2 --- /dev/null +++ b/src/AnthropicClient/Json/MessageBatchResultConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AnthropicClient.Models; + +namespace AnthropicClient.Json; + +class MessageBatchResultConverter : JsonConverter +{ + public override MessageBatchResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDocument = JsonDocument.ParseValue(ref reader); + var root = jsonDocument.RootElement; + var type = root.GetProperty("type").GetString(); + return type switch + { + MessageBatchResultType.Succeeded => JsonSerializer.Deserialize(root.GetRawText(), options)!, + MessageBatchResultType.Errored => JsonSerializer.Deserialize(root.GetRawText(), options)!, + MessageBatchResultType.Canceled => JsonSerializer.Deserialize(root.GetRawText(), options)!, + MessageBatchResultType.Expired => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown message batch result type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, MessageBatchResult value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CanceledMessageBatchResult.cs b/src/AnthropicClient/Models/CanceledMessageBatchResult.cs new file mode 100644 index 0000000..878bff6 --- /dev/null +++ b/src/AnthropicClient/Models/CanceledMessageBatchResult.cs @@ -0,0 +1,14 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a message batch result that was cancelled. +/// +public class CanceledMessageBatchResult : MessageBatchResult +{ + /// + /// Initializes a new instance of the class. + /// + public CanceledMessageBatchResult() : base(MessageBatchResultType.Canceled) + { + } +} diff --git a/src/AnthropicClient/Models/ErroredMessageBatchResult.cs b/src/AnthropicClient/Models/ErroredMessageBatchResult.cs new file mode 100644 index 0000000..4e7dbf5 --- /dev/null +++ b/src/AnthropicClient/Models/ErroredMessageBatchResult.cs @@ -0,0 +1,19 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a message batch result that contains an error response. +/// +public class ErroredMessageBatchResult : MessageBatchResult +{ + /// + /// Gets the error of the message batch result. + /// + public AnthropicError Error { get; init; } = new AnthropicError(); + + /// + /// Initializes a new instance of the class. + /// + public ErroredMessageBatchResult() : base(MessageBatchResultType.Errored) + { + } +} diff --git a/src/AnthropicClient/Models/ExpiredMessageBatchResult.cs b/src/AnthropicClient/Models/ExpiredMessageBatchResult.cs new file mode 100644 index 0000000..11f9f97 --- /dev/null +++ b/src/AnthropicClient/Models/ExpiredMessageBatchResult.cs @@ -0,0 +1,14 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a message batch result that has expired. +/// +public class ExpiredMessageBatchResult : MessageBatchResult +{ + /// + /// Initializes a new instance of the class. + /// + public ExpiredMessageBatchResult() : base(MessageBatchResultType.Expired) + { + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageBatchResult.cs b/src/AnthropicClient/Models/MessageBatchResult.cs new file mode 100644 index 0000000..6531475 --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchResult.cs @@ -0,0 +1,26 @@ +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a message batch result. +/// +public abstract class MessageBatchResult +{ + /// + /// Gets the type of the message batch result. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The type of the message batch result. + /// An instance of the class. + public MessageBatchResult(string type) + { + ArgumentValidator.ThrowIfNullOrWhitespace(type, nameof(type)); + + Type = type; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageBatchResultItem.cs b/src/AnthropicClient/Models/MessageBatchResultItem.cs new file mode 100644 index 0000000..14f9be6 --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchResultItem.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a message batch result item. +/// +public class MessageBatchResultItem +{ + /// + /// Gets the custom ID of the message batch result item. + /// + [JsonPropertyName("custom_id")] + public string CustomId { get; init; } = string.Empty; + + /// + /// Gets the result of the message batch result item. + /// + public MessageBatchResult Result { get; init; } = default!; +} diff --git a/src/AnthropicClient/Models/MessageBatchResultType.cs b/src/AnthropicClient/Models/MessageBatchResultType.cs new file mode 100644 index 0000000..187c2c2 --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchResultType.cs @@ -0,0 +1,27 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the types of message batch results. +/// +public static class MessageBatchResultType +{ + /// + /// Represents a succeeded message batch result. + /// + public const string Succeeded = "succeeded"; + + /// + /// Represents an errored message batch result. + /// + public const string Errored = "errored"; + + /// + /// Represents a canceled message batch result. + /// + public const string Canceled = "canceled"; + + /// + /// Represents an expired message batch result. + /// + public const string Expired = "expired"; +} diff --git a/src/AnthropicClient/Models/SucceededMessageBatchResult.cs b/src/AnthropicClient/Models/SucceededMessageBatchResult.cs new file mode 100644 index 0000000..82260e8 --- /dev/null +++ b/src/AnthropicClient/Models/SucceededMessageBatchResult.cs @@ -0,0 +1,20 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a message batch result that contains a message response. +/// +public class SucceededMessageBatchResult : MessageBatchResult +{ + /// + /// Gets the message of the message batch result. + /// + public MessageResponse Message { get; init; } = new MessageResponse(); + + /// + /// Initializes a new instance of the class. + /// + /// An instance of the class. + public SucceededMessageBatchResult() : base(MessageBatchResultType.Succeeded) + { + } +} diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 4909936..ad4c60b 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -1,10 +1,9 @@ +using AnthropicClient.Tests.Files; + namespace AnthropicClient.Tests.EndToEnd; public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(configFixture) { - private string GetTestFilePath(string fileName) => - Path.Combine(Directory.GetCurrentDirectory(), "Files", fileName); - [Fact] public async Task CreateMessageAsync_WhenCalled_ItShouldReturnResponse() { @@ -63,7 +62,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf [Fact] public async Task CreateMessageAsync_WhenImageIsSent_ItShouldReturnResponse() { - var imagePath = GetTestFilePath("elephant.jpg"); + var imagePath = TestFileHelper.GetTestFilePath("elephant.jpg"); var mediaType = "image/jpeg"; var bytes = await File.ReadAllBytesAsync(imagePath); var base64Data = Convert.ToBase64String(bytes); @@ -102,7 +101,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf { var client = CreateClient(new HttpClient()); - var storyPath = GetTestFilePath("story.txt"); + var storyPath = TestFileHelper.GetTestFilePath("story.txt"); var storyText = await File.ReadAllTextAsync(storyPath); var request = new MessageRequest( @@ -141,7 +140,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf { var client = CreateClient(new HttpClient()); - var storyPath = GetTestFilePath("story.txt"); + var storyPath = TestFileHelper.GetTestFilePath("story.txt"); var storyText = await File.ReadAllTextAsync(storyPath); var request = new MessageRequest( @@ -217,7 +216,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf [Fact] public async Task CreateMessageAsync_WhenProvidedWithPDF_ItShouldReturnResponse() { - var pdfPath = GetTestFilePath("addendum.pdf"); + var pdfPath = TestFileHelper.GetTestFilePath("addendum.pdf"); var bytes = await File.ReadAllBytesAsync(pdfPath); var base64Data = Convert.ToBase64String(bytes); @@ -253,7 +252,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf [Fact] public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShouldUseCache() { - var pdfPath = GetTestFilePath("addendum.pdf"); + var pdfPath = TestFileHelper.GetTestFilePath("addendum.pdf"); var bytes = await File.ReadAllBytesAsync(pdfPath); var base64Data = Convert.ToBase64String(bytes); @@ -354,9 +353,9 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf ) ), ]); - + var result = await _client.CreateMessageBatchAsync(request); - + result.IsSuccess.Should().BeTrue(); result.Value.Should().BeOfType(); result.Value.Id.Should().NotBeNullOrEmpty(); @@ -374,7 +373,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf ) ), ]); - + var createResult = await _client.CreateMessageBatchAsync(request); var getResult = await _client.GetMessageBatchAsync(createResult.Value.Id); diff --git a/tests/AnthropicClient.Tests/Files/TestFileHelper.cs b/tests/AnthropicClient.Tests/Files/TestFileHelper.cs new file mode 100644 index 0000000..efc9ca1 --- /dev/null +++ b/tests/AnthropicClient.Tests/Files/TestFileHelper.cs @@ -0,0 +1,7 @@ +namespace AnthropicClient.Tests.Files; + +static class TestFileHelper +{ + public static string GetTestFilePath(string fileName) => + Path.Combine(Directory.GetCurrentDirectory(), "Files", fileName); +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Files/batch_results.jsonl b/tests/AnthropicClient.Tests/Files/batch_results.jsonl new file mode 100644 index 0000000..3647eca --- /dev/null +++ b/tests/AnthropicClient.Tests/Files/batch_results.jsonl @@ -0,0 +1,5 @@ +{"custom_id":"my-fifth-request","result":{"type":"errored","error":{"type":"error","error":{"type":"not_found_error","message":"The requested resource could not be found."}}}} +{"custom_id":"my-fourth-request","result":{"type":"expired"}} +{"custom_id":"my-third-request","result":{"type":"canceled"}} +{"custom_id":"my-second-request","result":{"type":"succeeded","message":{"id":"msg_014VwiXbi91y3JMjcpyGBHX5","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"Hello again! It's nice to see you. How can I assist you today? Is there anything specific you'd like to chat about or any questions you have?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":36}}}} +{"custom_id":"my-first-request","result":{"type":"succeeded","message":{"id":"msg_01FqfsLoHwgeFbguDgpz48m7","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"Hello! How can I assist you today? Feel free to ask me any questions or let me know if there's anything you'd like to chat about."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":34}}}} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index d450495..f3fa91e 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1,3 +1,6 @@ +using AnthropicClient.Tests.Files; +using AnthropicClient.Tests.Unit; + namespace AnthropicClient.Tests.Integration; public class AnthropicApiClientTests : IntegrationTest @@ -1032,7 +1035,7 @@ public class AnthropicApiClientTests : IntegrationTest result.Value.CancelInitiatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); result.Value.ResultsUrl.Should().Be("https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"); } - + [Fact] public async Task CreateMessageBatchAsync_WhenCalledAndErrorReturned_ItShouldHandleError() { @@ -1058,7 +1061,7 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Should().BeOfType(); result.Error.Error.Should().BeOfType(); } - + [Fact] public async Task CreateMessageBatchAsync_WhenCalledRequestFailsAndErrorCanNotBeDeserialized_ItShouldReturnUnknownError() { @@ -1078,7 +1081,7 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Should().BeOfType(); result.Error.Error.Should().BeOfType(); } - + [Fact] public async Task CreateMessageBatchAsync_WhenCalledRequestSucceedsAndCanNotDeserializeResponse_ItShouldReturnEmptyResponse() { @@ -1153,7 +1156,7 @@ public class AnthropicApiClientTests : IntegrationTest result.Value.ResultsUrl.Should() .Be("https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"); } - + [Fact] public async Task GetMessageBatchAsync_WhenCalledAndErrorReturned_ItShouldHandleError() { @@ -1179,7 +1182,7 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Should().BeOfType(); result.Error.Error.Should().BeOfType(); } - + [Fact] public async Task GetMessageBatchAsync_WhenCalledRequestFailsAndErrorCanNotBeDeserialized_ItShouldReturnUnknownError() { @@ -1199,7 +1202,7 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Should().BeOfType(); result.Error.Error.Should().BeOfType(); } - + [Fact] public async Task GetMessageBatchAsync_WhenCalledAndCanNotDeserializeResponse_ItShouldReturnEmptyResponse() { @@ -1218,4 +1221,30 @@ public class AnthropicApiClientTests : IntegrationTest result.IsSuccess.Should().BeTrue(); result.Value.Should().BeOfType(); } + + [Fact] + public async Task GetMessageBatchResultsAsync_WhenCalledAndSuccessful_ItShouldReturnAllBatchResults() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + var batchResultsPath = TestFileHelper.GetTestFilePath("batch_results.jsonl"); + var batchResultsText = await File.ReadAllTextAsync(batchResultsPath); + var batchResults = batchResultsText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + var expectedResults = batchResults.Select(Deserialize); + + _mockHttpMessageHandler + .WhenGetMessageBatchResultsRequest(batchId) + .Respond( + HttpStatusCode.OK, + "application/x-jsonl", + batchResultsText + ); + + var result = await Client.GetMessageBatchResultsAsync(batchId); + + result.IsSuccess.Should().BeTrue(); + + var actualResults = await result.Value.ToListAsync(); + + actualResults.Should().BeEquivalentTo(expectedResults); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index b69a60d..90653d4 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -1,6 +1,8 @@ +using AnthropicClient.Tests.Unit; + namespace AnthropicClient.Tests.Integration; -public class IntegrationTest +public class IntegrationTest : SerializationTest { protected readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); protected AnthropicApiClient Client => CreateClient(); @@ -71,10 +73,16 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Post, MessageBatchesEndpoint); } - + public static MockedRequest WhenGetMessageBatchRequest(this MockHttpMessageHandler mockHttpMessageHandler, string batchId) { return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Get, $"{MessageBatchesEndpoint}/{batchId}"); } + + public static MockedRequest WhenGetMessageBatchResultsRequest(this MockHttpMessageHandler mockHttpMessageHandler, string batchId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, $"{MessageBatchesEndpoint}/{batchId}/results"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultItemTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultItemTests.cs new file mode 100644 index 0000000..5023018 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultItemTests.cs @@ -0,0 +1,14 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchResultItemTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstanceWithPropertiesSet() + { + var result = new MessageBatchResultItem(); + + result.Should().BeOfType(); + result.CustomId.Should().BeEmpty(); + result.Result.Should().Be(default); + } +} \ No newline at end of file From 3034544ca43cbb248b0f6ab4c4451c3ff4320046 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 9 Jan 2025 23:40:55 -0600 Subject: [PATCH 10/22] refactor: move local function below return --- src/AnthropicClient/AnthropicApiClient.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index cb5f0c5..fbe11c6 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -356,7 +356,9 @@ public class AnthropicApiClient : IAnthropicApiClient return AnthropicResult>.Failure(error, anthropicHeaders); } - async IAsyncEnumerable ReadResults() + return AnthropicResult>.Success(ReadResultsAsync(), anthropicHeaders); + + async IAsyncEnumerable ReadResultsAsync() { using var responseContent = await response.Content.ReadAsStreamAsync(); using var streamReader = new StreamReader(responseContent); @@ -371,8 +373,6 @@ public class AnthropicApiClient : IAnthropicApiClient line = await streamReader.ReadLineAsync(); } } - - return AnthropicResult>.Success(ReadResults(), anthropicHeaders); } /// From 299af3b759e116c77511baf56126565c4654f5d0 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:52:46 -0600 Subject: [PATCH 11/22] tests: add test for batch result type and succeeded batch result model --- .../Models/MessageBatchResultTypeTests.cs | 36 +++++++ .../SucceededMessageBatchResultTests.cs | 95 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTypeTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/SucceededMessageBatchResultTests.cs diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTypeTests.cs new file mode 100644 index 0000000..88381c5 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTypeTests.cs @@ -0,0 +1,36 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchResultTypeTests +{ + [Fact] + public void Succeeded_WhenCalled_ItShouldReturnExpectedValue() + { + var result = MessageBatchResultType.Succeeded; + + result.Should().Be("succeeded"); + } + + [Fact] + public void Errored_WhenCalled_ItShouldReturnExpectedValue() + { + var result = MessageBatchResultType.Errored; + + result.Should().Be("errored"); + } + + [Fact] + public void Canceled_WhenCalled_ItShouldReturnExpectedValue() + { + var result = MessageBatchResultType.Canceled; + + result.Should().Be("canceled"); + } + + [Fact] + public void Expired_WhenCalled_ItShouldReturnExpectedValue() + { + var result = MessageBatchResultType.Expired; + + result.Should().Be("expired"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/SucceededMessageBatchResultTests.cs b/tests/AnthropicClient.Tests/Unit/Models/SucceededMessageBatchResultTests.cs new file mode 100644 index 0000000..95ce4a0 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/SucceededMessageBatchResultTests.cs @@ -0,0 +1,95 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class SucceededMessageBatchResultTests : SerializationTest +{ + private const string SampleJson = @"{ + ""message"": { + ""id"": ""msg_01FqfsLoHwgeFbguDgpz48m7"", + ""model"": ""claude-3-5-sonnet-20240620"", + ""role"": ""assistant"", + ""stop_reason"": ""end_turn"", + ""type"": ""message"", + ""usage"": { + ""input_tokens"": 10, + ""output_tokens"": 34, + ""cache_creation_input_tokens"": 0, + ""cache_read_input_tokens"": 0 + }, + ""content"": [ + { + ""text"": ""Hello! How can I assist you today? Feel free to ask me any questions or let me know if there\u0027s anything you\u0027d like to chat about."", + ""type"": ""text"" + } + ] + }, + ""type"": ""succeeded"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet() + { + var result = new SucceededMessageBatchResult(); + + result.Type.Should().Be(MessageBatchResultType.Succeeded); + result.Message.Should().BeEquivalentTo(new MessageResponse()); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var result = new SucceededMessageBatchResult + { + Message = new MessageResponse + { + Id = "msg_01FqfsLoHwgeFbguDgpz48m7", + Type = "message", + Role = "assistant", + Model = "claude-3-5-sonnet-20240620", + Content = [ + new TextContent() + { + Text = "Hello! How can I assist you today? Feel free to ask me any questions or let me know if there's anything you'd like to chat about." + } + ], + StopReason = "end_turn", + Usage = new() + { + InputTokens = 10, + OutputTokens = 34 + } + } + }; + + var json = Serialize(result); + + JsonAssert.Equal(SampleJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedValues() + { + var result = Deserialize(SampleJson); + + result!.Type.Should().Be(MessageBatchResultType.Succeeded); + result.Message.Should().BeEquivalentTo(new MessageResponse + { + Id = "msg_01FqfsLoHwgeFbguDgpz48m7", + Type = "message", + Role = "assistant", + Model = "claude-3-5-sonnet-20240620", + Content = [ + new TextContent() + { + Text = "Hello! How can I assist you today? Feel free to ask me any questions or let me know if there's anything you'd like to chat about." + } + ], + StopReason = "end_turn", + StopSequence = null, + Usage = new() + { + InputTokens = 10, + OutputTokens = 34 + } + }); + } +} \ No newline at end of file From 876c6f571c99082c401259d35fa0e30bac94a328 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 12 Jan 2025 13:26:31 -0600 Subject: [PATCH 12/22] tests: cover null responses --- src/AnthropicClient/AnthropicApiClient.cs | 1 - .../Json/ContentDeltaConverter.cs | 1 - .../Json/EventDataConverter.cs | 1 - .../Integration/AnthropicApiClientTests.cs | 239 +++++++++++++++++- .../Models/CanceledMessageBatchResultTests.cs | 34 +++ .../Unit/Models/ErroredMessageBatchResult.cs | 48 ++++ .../Models/ExpiredMessageBatchResultTests.cs | 34 +++ .../Unit/Models/MessageBatchResultTests.cs | 25 ++ .../Unit/Models/ToolCallTests.cs | 2 - .../Unit/SerializationTest.cs | 2 - 10 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CanceledMessageBatchResultTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ErroredMessageBatchResult.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ExpiredMessageBatchResultTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTests.cs diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index fbe11c6..add0174 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -1,7 +1,6 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using System.Threading.Tasks; using AnthropicClient.Json; using AnthropicClient.Models; diff --git a/src/AnthropicClient/Json/ContentDeltaConverter.cs b/src/AnthropicClient/Json/ContentDeltaConverter.cs index e384b8a..af2dbb8 100644 --- a/src/AnthropicClient/Json/ContentDeltaConverter.cs +++ b/src/AnthropicClient/Json/ContentDeltaConverter.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/src/AnthropicClient/Json/EventDataConverter.cs b/src/AnthropicClient/Json/EventDataConverter.cs index d11855b..31bb6fe 100644 --- a/src/AnthropicClient/Json/EventDataConverter.cs +++ b/src/AnthropicClient/Json/EventDataConverter.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index f3fa91e..e5a5a33 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1,5 +1,4 @@ using AnthropicClient.Tests.Files; -using AnthropicClient.Tests.Unit; namespace AnthropicClient.Tests.Integration; @@ -35,6 +34,51 @@ public class AnthropicApiClientTests : IntegrationTest actualErrorType.Should().Be(errorType); } + [Fact] + public async Task CreateMessageAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() + { + _mockHttpMessageHandler + .WhenCreateMessageRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"null" + ); + + var request = new MessageRequest( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ); + + var result = await Client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task CreateMessageAsync_WhenCalledRequestSucceedsAndCanNotDeserializeResponse_ItShouldReturnEmptyResponse() + { + _mockHttpMessageHandler + .WhenCreateMessageRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"null" + ); + + var request = new MessageRequest( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ); + + var result = await Client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEquivalentTo(new MessageResponse()); + } + [Fact] public async Task CreateMessageAsync_WhenCalledAndMessageCreatedWithTextContent_ItShouldReturnMessage() { @@ -363,6 +407,34 @@ public class AnthropicApiClientTests : IntegrationTest )); } + [Fact] + public async Task CreateMessageAsync_WhenCalledMessageIsStreamedRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownErrorEvent() + { + _mockHttpMessageHandler + .WhenCreateStreamMessageRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"null" + ); + + var request = new StreamMessageRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [new TextContent("Hello!")]), + new(MessageRole.User, [new TextContent("Hello!")]) + ] + ); + + var result = Client.CreateMessageAsync(request); + var events = await result.ToListAsync(); + + events.Should().HaveCount(1); + events[0].Type.Should().Be(EventType.Error); + events[0].Data.Should().BeOfType(); + events[0].Data.Should().BeEquivalentTo(new ErrorEventData(new ApiError())); + } + [Fact] public async Task CreateMessageAsync_WhenCalledAndMessageCreatedWithDocumentContent_ItShouldReturnMessage() { @@ -489,7 +561,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.BadRequest, "application/json", - @"{}" + @"null" ); var request = new CountMessageTokensRequest( @@ -507,6 +579,31 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Error.Should().BeOfType(); } + [Fact] + public async Task CountMessageTokensAsync_WhenCalledAndResponseCanNotBeDeserialized_ItShouldReturnEmptyResponse() + { + _mockHttpMessageHandler + .WhenCountMessageTokensRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"null" + ); + + var request = new CountMessageTokensRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [new TextContent("Hello!")]), + ] + ); + + var result = await Client.CountMessageTokensAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.InputTokens.Should().Be(0); + } + [Fact] public async Task ListModelsAsync_WhenCalledWithPagingRequestUsingDefaultValues_ItShouldReturnListOfModels() { @@ -656,7 +753,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.BadRequest, "application/json", - @"{}" + @"null" ); var result = await Client.ListModelsAsync(); @@ -666,6 +763,27 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Error.Should().BeOfType(); } + [Fact] + public async Task ListModelAsync_WhenCalledRequestSucceedsAndCanNotDeserializeResponse_ItShouldReturnEmptyPage() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"null" + ); + + var result = await Client.ListModelsAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.HasMore.Should().BeFalse(); + result.Value.FirstId.Should().BeEmpty(); + result.Value.LastId.Should().BeEmpty(); + result.Value.Data.Should().BeEmpty(); + } + [Fact] public async Task ListAllModelsAsync_WhenCalled_ItShouldReturnAllModels() { @@ -803,7 +921,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.BadRequest, "application/json", - @"{}" + @"null" ); var responses = Client.ListAllModelsAsync(); @@ -889,6 +1007,35 @@ public class AnthropicApiClientTests : IntegrationTest count.Should().Be(2); } + [Fact] + public async Task ListAllModelsAsync_WhenFirstPageSucceedsButResponseCanNotBeDeserialized_ItShouldReturnEmptyPage() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .WithExactQueryString(new Dictionary + { + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"null" + ); + + var responses = Client.ListAllModelsAsync(); + var count = 0; + + await foreach (var page in responses) + { + count++; + page.IsSuccess.Should().BeTrue(); + page.Value.Should().BeOfType>(); + page.Value.Data.Should().BeEmpty(); + } + + count.Should().Be(1); + } + [Fact] public async Task GetModelAsync_WhenCalled_ItShouldReturnModel() { @@ -953,7 +1100,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.BadRequest, "application/json", - @"{}" + @"null" ); var result = await Client.GetModelAsync(modelId); @@ -973,7 +1120,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.OK, "application/json", - @"{}" + @"null" ); var result = await Client.GetModelAsync(modelId); @@ -1070,7 +1217,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.BadRequest, "application/json", - @"{}" + @"null" ); var request = new MessageBatchRequest([new("custom_id", new())]); @@ -1090,7 +1237,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.OK, "application/json", - @"{}" + @"null" ); var request = new MessageBatchRequest([new("custom_id", new())]); @@ -1193,7 +1340,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.BadRequest, "application/json", - @"{}" + @"null" ); var result = await Client.GetMessageBatchAsync(batchId); @@ -1213,7 +1360,7 @@ public class AnthropicApiClientTests : IntegrationTest .Respond( HttpStatusCode.OK, "application/json", - @"{}" + @"null" ); var result = await Client.GetMessageBatchAsync(batchId); @@ -1247,4 +1394,76 @@ public class AnthropicApiClientTests : IntegrationTest actualResults.Should().BeEquivalentTo(expectedResults); } + + [Fact] + public async Task GetMessageBatchResultsAsync_WhenCalledSuccessfulAndResultCanNotBeDeserialized_ItShouldReturnEmptyResults() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + var expectedResults = new List() + { + new(), + }; + + _mockHttpMessageHandler + .WhenGetMessageBatchResultsRequest(batchId) + .Respond( + HttpStatusCode.OK, + "application/x-jsonl", + "null" + ); + + var result = await Client.GetMessageBatchResultsAsync(batchId); + + result.IsSuccess.Should().BeTrue(); + + var actualResults = await result.Value.ToListAsync(); + + actualResults.Should().BeEquivalentTo(expectedResults); + } + + [Fact] + public async Task GetMessageBatchResultsAsync_WhenCalledAndRequestFails_ItShouldReturnError() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetMessageBatchResultsRequest(batchId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row"" + } + }" + ); + + var result = await Client.GetMessageBatchResultsAsync(batchId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task GetMessageBatchResultsAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenGetMessageBatchResultsRequest(batchId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"null" + ); + + var result = await Client.GetMessageBatchResultsAsync(batchId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/CanceledMessageBatchResultTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CanceledMessageBatchResultTests.cs new file mode 100644 index 0000000..ac68fec --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CanceledMessageBatchResultTests.cs @@ -0,0 +1,34 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CanceledMessageBatchResultTests : SerializationTest +{ + private const string SampleJson = @"{ + ""type"": ""canceled"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet() + { + var result = new CanceledMessageBatchResult(); + + result.Type.Should().Be(MessageBatchResultType.Canceled); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var result = new CanceledMessageBatchResult(); + + var json = Serialize(result); + + JsonAssert.Equal(SampleJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedProperties() + { + var result = Deserialize(SampleJson); + + result!.Type.Should().Be(MessageBatchResultType.Canceled); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ErroredMessageBatchResult.cs b/tests/AnthropicClient.Tests/Unit/Models/ErroredMessageBatchResult.cs new file mode 100644 index 0000000..0fd7f69 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ErroredMessageBatchResult.cs @@ -0,0 +1,48 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ErroredMessageBatchResultTests : SerializationTest +{ + private const string SampleJson = @"{ + ""type"": ""errored"", + ""error"": { + ""type"": ""error"", + ""error"": { + ""type"": ""api_error"", + ""message"": ""An error occurred."" + } + } + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet() + { + var result = new ErroredMessageBatchResult(); + + result.Type.Should().Be(MessageBatchResultType.Errored); + result.Error.Error.Should().BeOfType(); + result.Error.Error.Message.Should().BeEmpty(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var result = new ErroredMessageBatchResult + { + Error = new(new ApiError("An error occurred.")) + }; + + var json = Serialize(result); + + JsonAssert.Equal(SampleJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedProperties() + { + var result = Deserialize(SampleJson); + + result!.Type.Should().Be(MessageBatchResultType.Errored); + result.Error.Error.Should().BeOfType(); + result.Error.Error.Message.Should().Be("An error occurred."); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ExpiredMessageBatchResultTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ExpiredMessageBatchResultTests.cs new file mode 100644 index 0000000..d8e4156 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ExpiredMessageBatchResultTests.cs @@ -0,0 +1,34 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ExpiredMessageBatchResultTests : SerializationTest +{ + private const string SampleJson = @"{ + ""type"": ""expired"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet() + { + var result = new ExpiredMessageBatchResult(); + + result.Type.Should().Be(MessageBatchResultType.Expired); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var result = new ExpiredMessageBatchResult(); + + var json = Serialize(result); + + JsonAssert.Equal(SampleJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedProperties() + { + var result = Deserialize(SampleJson); + + result!.Type.Should().Be(MessageBatchResultType.Expired); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTests.cs new file mode 100644 index 0000000..dd873d6 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResultTests.cs @@ -0,0 +1,25 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchResultTests : SerializationTest +{ + [Fact] + public void JsonDeserialization_WhenHasUnknownType_ItShouldThrowException() + { + var json = @"{""type"":""unknown""}"; + + var action = () => Deserialize(json); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var expectedJson = @"{""type"":""expired""}"; + var messageBatchResult = new ExpiredMessageBatchResult(); + + var json = Serialize(messageBatchResult); + + JsonAssert.Equal(expectedJson, json); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ToolCallTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ToolCallTests.cs index 97240a4..a338a05 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/ToolCallTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/ToolCallTests.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Nodes; - namespace AnthropicClient.Tests.Unit.Models; public class ToolCallTests : SerializationTest diff --git a/tests/AnthropicClient.Tests/Unit/SerializationTest.cs b/tests/AnthropicClient.Tests/Unit/SerializationTest.cs index 06dd487..8fdd0df 100644 --- a/tests/AnthropicClient.Tests/Unit/SerializationTest.cs +++ b/tests/AnthropicClient.Tests/Unit/SerializationTest.cs @@ -1,5 +1,3 @@ -using AnthropicClient.Json; - namespace AnthropicClient.Tests.Unit; public class SerializationTest From ca2ecdcfc79a5ff96c22eb564e7fc8be22e82a6f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 12 Jan 2025 13:46:05 -0600 Subject: [PATCH 13/22] feat: implement ListMessageBatchesAsync method --- src/AnthropicClient/AnthropicApiClient.cs | 26 ++ .../EndToEnd/AnthropicApiClientTests.cs | 24 +- .../Integration/AnthropicApiClientTests.cs | 230 ++++++++++++++++++ .../Integration/IntegrationTest.cs | 6 + 4 files changed, 285 insertions(+), 1 deletion(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index add0174..bed8011 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -41,6 +41,13 @@ public interface IAnthropicApiClient /// A task that represents the asynchronous operation. The task result contains the response as an where T is . Task> GetMessageBatchAsync(string batchId); + /// + /// Lists the message batches asynchronously. + /// + /// The paging request to use for listing the message batches. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . + Task>> ListMessageBatchesAsync(PagingRequest? request = null); + /// /// Gets the results of a message batch asynchronously. /// @@ -342,6 +349,25 @@ public class AnthropicApiClient : IAnthropicApiClient return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); } + /// + public async Task>> ListMessageBatchesAsync(PagingRequest? request = null) + { + var pagingRequest = request ?? new PagingRequest(); + var endpoint = $"{MessageBatchesEndpoint}?{pagingRequest.ToQueryParameters()}"; + var response = await SendRequestAsync(endpoint); + var anthropicHeaders = new AnthropicHeaders(response.Headers); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + var error = Deserialize(responseContent) ?? new AnthropicError(); + return AnthropicResult>.Failure(error, anthropicHeaders); + } + + var page = Deserialize>(responseContent) ?? new Page(); + return AnthropicResult>.Success(page, anthropicHeaders); + } + /// public async Task>> GetMessageBatchResultsAsync(string batchId) { diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index ad4c60b..5cb7170 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -2,7 +2,7 @@ using AnthropicClient.Tests.Files; namespace AnthropicClient.Tests.EndToEnd; -public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(configFixture) +public class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndToEndTest(configFixture) { [Fact] public async Task CreateMessageAsync_WhenCalled_ItShouldReturnResponse() @@ -381,4 +381,26 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf getResult.Value.Should().BeOfType(); getResult.Value.Id.Should().Be(createResult.Value.Id); } + + [Fact] + public async Task ListMessageBatchesAsync_WhenCalled_ItShouldReturnResponse() + { + var request = new MessageBatchRequest([ + new( + Guid.NewGuid().ToString(), + new( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ) + ), + ]); + + var createResult = await _client.CreateMessageBatchAsync(request); + var result = await _client.ListMessageBatchesAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.Data.Should().HaveCountGreaterThan(0); + result.Value.Data.Should().ContainSingle(b => b.Id == createResult.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 e5a5a33..76d4bea 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1466,4 +1466,234 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Should().BeOfType(); result.Error.Error.Should().BeOfType(); } + + [Fact] + public async Task ListMessageBatchesAsync_WhenCalledAndSuccessful_ItShouldReturnPageOfBatches() + { + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + var result = await Client.ListMessageBatchesAsync(); + + 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 MessageBatchResponse[] + { + new() + { + Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + Type = "message_batch", + ProcessingStatus = "in_progress", + RequestCounts = new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }, + EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results" + } + }); + } + + [Fact] + public async Task ListMessageBatchesAsync_WhenCalledWithPagingRequestAndSuccessful_ItShouldReturnPageOfBatches() + { + var pagingRequest = new PagingRequest(afterId: "next_id", limit: 10); + + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .WithQueryString(new Dictionary + { + { "after_id", pagingRequest.AfterId }, + { "limit", pagingRequest.Limit.ToString() }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + var result = await Client.ListMessageBatchesAsync(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 MessageBatchResponse[] + { + new() + { + Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + Type = "message_batch", + ProcessingStatus = "in_progress", + RequestCounts = new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }, + EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results" + } + }); + } + + [Fact] + public async Task ListMessageBatchesAsync_WhenCalledAndNoBatchesReturned_ItShouldReturnEmptyList() + { + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [], + ""has_more"": false, + ""first_id"": null, + ""last_id"": null + }" + ); + + var result = await Client.ListMessageBatchesAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.HasMore.Should().BeFalse(); + result.Value.FirstId.Should().BeNull(); + result.Value.LastId.Should().BeNull(); + result.Value.Data.Should().BeEmpty(); + } + + [Fact] + public async Task ListMessageBatchesAsync_WhenCalledAndErrorReturned_ItShouldHandleError() + { + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row"" + } + }" + ); + + var result = await Client.ListMessageBatchesAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task ListMessageBatchesAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() + { + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"null" + ); + + var result = await Client.ListMessageBatchesAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task ListMessageBatchesAsync_WhenCalledRequestSucceedsAndCanNotDeserializeResponse_ItShouldReturnEmptyPage() + { + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"null" + ); + + var result = await Client.ListMessageBatchesAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.HasMore.Should().BeFalse(); + result.Value.FirstId.Should().BeEmpty(); + result.Value.LastId.Should().BeEmpty(); + result.Value.Data.Should().BeEmpty(); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index 90653d4..465319d 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -85,4 +85,10 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Get, $"{MessageBatchesEndpoint}/{batchId}/results"); } + + public static MockedRequest WhenListMessageBatchesRequest(this MockHttpMessageHandler mockHttpMessageHandler) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, MessageBatchesEndpoint); + } } \ No newline at end of file From 180a0901959b88b27981f063b0be7a267a141802 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:42:23 -0600 Subject: [PATCH 14/22] feat: add ListAllMessageBatchesAsync method and corresponding tests --- src/AnthropicClient/AnthropicApiClient.cs | 84 +++++++----- .../EndToEnd/AnthropicApiClientTests.cs | 32 +++++ .../Integration/AnthropicApiClientTests.cs | 127 ++++++++++++++++++ 3 files changed, 213 insertions(+), 30 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index bed8011..8c0b3fb 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -48,6 +48,13 @@ public interface IAnthropicApiClient /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . Task>> ListMessageBatchesAsync(PagingRequest? request = null); + /// + /// Lists all message batches asynchronously. + /// + /// The maximum number of message batches to return in each page. + /// An asynchronous enumerable that yields the response as an where T is where T is . + IAsyncEnumerable>> ListAllMessageBatchesAsync(int limit = 20); + /// /// Gets the results of a message batch asynchronously. /// @@ -368,6 +375,15 @@ public class AnthropicApiClient : IAnthropicApiClient return AnthropicResult>.Success(page, anthropicHeaders); } + /// + public async IAsyncEnumerable>> ListAllMessageBatchesAsync(int limit = 20) + { + await foreach (var result in GetAllPagesAsync(MessageBatchesEndpoint, limit)) + { + yield return result; + } + } + /// public async Task>> GetMessageBatchResultsAsync(string batchId) { @@ -439,37 +455,10 @@ public class AnthropicApiClient : IAnthropicApiClient /// public async IAsyncEnumerable>> ListAllModelsAsync(int limit = 20) { - var pagingRequest = new PagingRequest(limit: limit); - string Endpoint() => $"{ModelsEndpoint}?{pagingRequest.ToQueryParameters()}"; - bool hasMore; - - do + await foreach (var result in GetAllPagesAsync(ModelsEndpoint, limit)) { - var response = await SendRequestAsync(Endpoint()); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - yield return AnthropicResult>.Failure(error, anthropicHeaders); - yield break; - } - - var page = Deserialize>(responseContent) ?? new Page(); - - if (page.HasMore && page.LastId is not null) - { - hasMore = true; - pagingRequest = new PagingRequest(limit: limit, afterId: page.LastId); - } - else - { - hasMore = false; - } - - yield return AnthropicResult>.Success(page, anthropicHeaders); - } while (hasMore); + yield return result; + } } /// @@ -490,6 +479,41 @@ public class AnthropicApiClient : IAnthropicApiClient return AnthropicResult.Success(model, anthropicHeaders); } + private async IAsyncEnumerable>> GetAllPagesAsync(string endpoint, int limit = 20) + { + var pagingRequest = new PagingRequest(limit: limit); + string Endpoint() => $"{endpoint}?{pagingRequest.ToQueryParameters()}"; + bool hasMore; + + do + { + var response = await SendRequestAsync(Endpoint()); + var anthropicHeaders = new AnthropicHeaders(response.Headers); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + var error = Deserialize(responseContent) ?? new AnthropicError(); + yield return AnthropicResult>.Failure(error, anthropicHeaders); + yield break; + } + + var page = Deserialize>(responseContent) ?? new Page(); + + if (page.HasMore && page.LastId is not null) + { + hasMore = true; + pagingRequest = new PagingRequest(limit: limit, afterId: page.LastId); + } + else + { + hasMore = false; + } + + yield return AnthropicResult>.Success(page, anthropicHeaders); + } while (hasMore); + } + private ToolCall? GetToolCall(MessageResponse response, List tools) { var toolUse = response.Content.OfType().FirstOrDefault(); diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 5cb7170..4cdd660 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -403,4 +403,36 @@ public class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndTo result.Value.Data.Should().HaveCountGreaterThan(0); result.Value.Data.Should().ContainSingle(b => b.Id == createResult.Value.Id); } + + [Fact] + public async Task ListAllMessageBatchesAsync_WhenCalled_ItShouldReturnResponse() + { + var createRequest = (string id) => new MessageBatchRequest([ + new( + id, + new( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ) + ), + ]); + + var requestNumberOne = createRequest(Guid.NewGuid().ToString()); + var requestNumberTwo = createRequest(Guid.NewGuid().ToString()); + + var createResultOne = await _client.CreateMessageBatchAsync(requestNumberOne); + var createResultTwo = await _client.CreateMessageBatchAsync(requestNumberTwo); + + var responses = await _client.ListAllMessageBatchesAsync(limit: 1).ToListAsync(); + + responses.Should().HaveCountGreaterThan(2); + + var batches = responses + .Where(r => r.IsSuccess) + .Select(r => r.Value) + .SelectMany(r => r.Data); + + batches.Should().ContainSingle(b => b.Id == createResultOne.Value.Id); + batches.Should().ContainSingle(b => b.Id == createResultTwo.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 76d4bea..2b458c2 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1696,4 +1696,131 @@ public class AnthropicApiClientTests : IntegrationTest result.Value.LastId.Should().BeEmpty(); result.Value.Data.Should().BeEmpty(); } + + [Fact] + public async Task ListAllMessageBatchesAsync_WhenCalled_ItShouldReturnAllBatches() + { + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .WithExactQueryString(new Dictionary() + { + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + _mockHttpMessageHandler + .WhenListMessageBatchesRequest() + .WithExactQueryString(new Dictionary() + { + { "after_id", "1" }, + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + } + ], + ""has_more"": false, + ""first_id"": ""2"", + ""last_id"": ""2"" + }" + ); + + var pageResponses = Client.ListAllMessageBatchesAsync(); + var collectedPages = new List>(); + + await foreach (var response in pageResponses) + { + response.IsSuccess.Should().BeTrue(); + response.Value.Should().BeOfType>(); + collectedPages.Add(response.Value); + } + + var expectedMessageBatchResponse = new MessageBatchResponse() + { + Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + Type = "message_batch", + ProcessingStatus = "in_progress", + RequestCounts = new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }, + EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results" + }; + + collectedPages.Should().HaveCount(2); + collectedPages.Should().BeEquivalentTo(new List>() + { + new() + { + Data = [expectedMessageBatchResponse], + FirstId = "1", + LastId = "1", + HasMore = true + }, + new() + { + Data = [expectedMessageBatchResponse], + FirstId = "2", + LastId = "2", + HasMore = false + } + }); + } } \ No newline at end of file From c11600c77040eebfab4df1153d07774357d87ce2 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:11:47 -0600 Subject: [PATCH 15/22] feat: add CancelMessageBatchAsync method and corresponding tests --- src/AnthropicClient/AnthropicApiClient.cs | 30 ++++- .../Integration/AnthropicApiClientTests.cs | 117 +++++++++++++++--- .../Integration/IntegrationTest.cs | 6 + 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 8c0b3fb..34244c3 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -55,6 +55,13 @@ public interface IAnthropicApiClient /// An asynchronous enumerable that yields the response as an where T is where T is . IAsyncEnumerable>> ListAllMessageBatchesAsync(int limit = 20); + /// + /// Cancels a message batch asynchronously. + /// + /// The ID of the message batch to cancel. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> CancelMessageBatchAsync(string batchId); + /// /// Gets the results of a message batch asynchronously. /// @@ -384,6 +391,24 @@ public class AnthropicApiClient : IAnthropicApiClient } } + /// + public async Task> CancelMessageBatchAsync(string batchId) + { + var endpoint = $"{MessageBatchesEndpoint}/{batchId}/cancel"; + var response = await SendRequestAsync(endpoint, HttpMethod.Post); + var anthropicHeaders = new AnthropicHeaders(response.Headers); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + var error = Deserialize(responseContent) ?? new AnthropicError(); + return AnthropicResult.Failure(error, anthropicHeaders); + } + + var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse(); + return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); + } + /// public async Task>> GetMessageBatchResultsAsync(string batchId) { @@ -533,9 +558,10 @@ public class AnthropicApiClient : IAnthropicApiClient return new ToolCall(tool, toolUse); } - private async Task SendRequestAsync(string endpoint) + private async Task SendRequestAsync(string endpoint, HttpMethod? method = null) { - return await _httpClient.GetAsync(endpoint); + var request = new HttpRequestMessage(method ?? HttpMethod.Get, endpoint); + return await _httpClient.SendAsync(request); } private async Task SendRequestAsync(string endpoint, T request) diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 2b458c2..8b312c2 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1282,26 +1282,26 @@ public class AnthropicApiClientTests : IntegrationTest var result = await Client.GetMessageBatchAsync(batchId); result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeOfType(); - result.Value.Id.Should().Be("msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"); - result.Value.Type.Should().Be("message_batch"); - result.Value.ProcessingStatus.Should().Be("in_progress"); - result.Value.RequestCounts.Should().BeEquivalentTo(new MessageBatchRequestCounts + result.Value.Should().BeEquivalentTo(new MessageBatchResponse() { - Processing = 100, - Succeeded = 50, - Errored = 30, - Canceled = 10, - Expired = 10 + Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + Type = "message_batch", + ProcessingStatus = "in_progress", + RequestCounts = new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }, + EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results" }); - - result.Value.EndedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); - result.Value.CreatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); - result.Value.ExpiresAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); - result.Value.ArchivedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); - result.Value.CancelInitiatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z")); - result.Value.ResultsUrl.Should() - .Be("https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"); } [Fact] @@ -1823,4 +1823,85 @@ public class AnthropicApiClientTests : IntegrationTest } }); } + + [Fact] + public async Task CancelMessageBatchAsync_WhenCalled_ItShouldReturnBatch() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenCancelMessageBatchRequest(batchId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch"", + ""processing_status"": ""in_progress"", + ""request_counts"": { + ""processing"": 100, + ""succeeded"": 50, + ""errored"": 30, + ""canceled"": 10, + ""expired"": 10 + }, + ""ended_at"": ""2024-08-20T18:37:24.100435Z"", + ""created_at"": ""2024-08-20T18:37:24.100435Z"", + ""expires_at"": ""2024-08-20T18:37:24.100435Z"", + ""archived_at"": ""2024-08-20T18:37:24.100435Z"", + ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"", + ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"" + }" + ); + + var result = await Client.CancelMessageBatchAsync(batchId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEquivalentTo(new MessageBatchResponse() + { + Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + Type = "message_batch", + ProcessingStatus = "in_progress", + RequestCounts = new MessageBatchRequestCounts + { + Processing = 100, + Succeeded = 50, + Errored = 30, + Canceled = 10, + Expired = 10 + }, + EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"), + ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results" + }); + } + + [Fact] + public async Task CancelMessageBatchAsync_WhenCalledAndFails_ItShouldReturnError() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenCancelMessageBatchRequest(batchId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""batch: batch not found"" + } + }" + ); + + var result = await Client.CancelMessageBatchAsync(batchId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index 465319d..e521d0c 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -91,4 +91,10 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Get, MessageBatchesEndpoint); } + + public static MockedRequest WhenCancelMessageBatchRequest(this MockHttpMessageHandler mockHttpMessageHandler, string batchId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Post, $"{MessageBatchesEndpoint}/{batchId}/cancel"); + } } \ No newline at end of file From a3ac1001f65f6a1ddfc5c285b497f5122e4ff19e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 14 Jan 2025 12:57:52 -0600 Subject: [PATCH 16/22] tests: add end to end test for canceling a batch --- .../EndToEnd/AnthropicApiClientTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 4cdd660..8fa81f6 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -435,4 +435,26 @@ public class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndTo batches.Should().ContainSingle(b => b.Id == createResultOne.Value.Id); batches.Should().ContainSingle(b => b.Id == createResultTwo.Value.Id); } + + [Fact] + public async Task CancelMessageBatchAsync_WhenCalled_ItShouldReturnResponse() + { + var request = new MessageBatchRequest([ + new( + Guid.NewGuid().ToString(), + new( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ) + ), + ]); + + var createResult = await _client.CreateMessageBatchAsync(request); + var result = await _client.CancelMessageBatchAsync(createResult.Value.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be(createResult.Value.Id); + result.Value.ProcessingStatus.Should().Be(MessageBatchStatus.Canceling); + } } \ No newline at end of file From 7ec931dda53678899713cc92fad0b068ae622313 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 14 Jan 2025 13:14:14 -0600 Subject: [PATCH 17/22] refactor: add private method to dry class up a bit --- src/AnthropicClient/AnthropicApiClient.cs | 99 +++++------------------ 1 file changed, 22 insertions(+), 77 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 34244c3..13ceafe 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -333,34 +333,14 @@ public class AnthropicApiClient : IAnthropicApiClient public async Task> CreateMessageBatchAsync(MessageBatchRequest request) { var response = await SendRequestAsync(MessageBatchesEndpoint, request); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult.Failure(error, anthropicHeaders); - } - - var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse(); - return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); + return await CreateResultAsync(response); } /// public async Task> GetMessageBatchAsync(string batchId) { var response = await SendRequestAsync($"{MessageBatchesEndpoint}/{batchId}"); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult.Failure(error, anthropicHeaders); - } - - var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse(); - return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); + return await CreateResultAsync(response); } /// @@ -369,17 +349,7 @@ public class AnthropicApiClient : IAnthropicApiClient var pagingRequest = request ?? new PagingRequest(); var endpoint = $"{MessageBatchesEndpoint}?{pagingRequest.ToQueryParameters()}"; var response = await SendRequestAsync(endpoint); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult>.Failure(error, anthropicHeaders); - } - - var page = Deserialize>(responseContent) ?? new Page(); - return AnthropicResult>.Success(page, anthropicHeaders); + return await CreateResultAsync>(response); } /// @@ -396,17 +366,7 @@ public class AnthropicApiClient : IAnthropicApiClient { var endpoint = $"{MessageBatchesEndpoint}/{batchId}/cancel"; var response = await SendRequestAsync(endpoint, HttpMethod.Post); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult.Failure(error, anthropicHeaders); - } - - var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse(); - return AnthropicResult.Success(msgBatchResponse, anthropicHeaders); + return await CreateResultAsync(response); } /// @@ -445,17 +405,7 @@ public class AnthropicApiClient : IAnthropicApiClient public async Task> CountMessageTokensAsync(CountMessageTokensRequest request) { var response = await SendRequestAsync(CountTokensEndpoint, request); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult.Failure(error, anthropicHeaders); - } - - var msgResponse = Deserialize(responseContent) ?? new TokenCountResponse(); - return AnthropicResult.Success(msgResponse, anthropicHeaders); + return await CreateResultAsync(response); } /// @@ -464,17 +414,7 @@ public class AnthropicApiClient : IAnthropicApiClient var pagingRequest = request ?? new PagingRequest(); var endpoint = $"{ModelsEndpoint}?{pagingRequest.ToQueryParameters()}"; var response = await SendRequestAsync(endpoint); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult>.Failure(error, anthropicHeaders); - } - - var page = Deserialize>(responseContent) ?? new Page(); - return AnthropicResult>.Success(page, anthropicHeaders); + return await CreateResultAsync>(response); } /// @@ -491,17 +431,7 @@ public class AnthropicApiClient : IAnthropicApiClient { var endpoint = $"{ModelsEndpoint}/{modelId}"; var response = await SendRequestAsync(endpoint); - var anthropicHeaders = new AnthropicHeaders(response.Headers); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult.Failure(error, anthropicHeaders); - } - - var model = Deserialize(responseContent) ?? new AnthropicModel(); - return AnthropicResult.Success(model, anthropicHeaders); + return await CreateResultAsync(response); } private async IAsyncEnumerable>> GetAllPagesAsync(string endpoint, int limit = 20) @@ -558,6 +488,21 @@ public class AnthropicApiClient : IAnthropicApiClient return new ToolCall(tool, toolUse); } + private async Task> CreateResultAsync(HttpResponseMessage response) where T : new() + { + var anthropicHeaders = new AnthropicHeaders(response.Headers); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + var error = Deserialize(responseContent) ?? new AnthropicError(); + return AnthropicResult.Failure(error, anthropicHeaders); + } + + var model = Deserialize(responseContent) ?? new T(); + return AnthropicResult.Success(model, anthropicHeaders); + } + private async Task SendRequestAsync(string endpoint, HttpMethod? method = null) { var request = new HttpRequestMessage(method ?? HttpMethod.Get, endpoint); From 56b3a53a2a31070693351525379b6a675b068496 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 14 Jan 2025 13:41:32 -0600 Subject: [PATCH 18/22] feat: add DeleteMessageBatchAsync method and MessageBatchDeleteResponse model with tests --- src/AnthropicClient/AnthropicApiClient.cs | 99 ++--------------- src/AnthropicClient/IAnthropicApiClient.cs | 101 ++++++++++++++++++ .../Models/MessageBatchDeleteResponse.cs | 17 +++ .../Integration/AnthropicApiClientTests.cs | 24 +++++ .../Integration/IntegrationTest.cs | 6 ++ .../Models/MessageBatchDeleteResponseTests.cs | 41 +++++++ 6 files changed, 197 insertions(+), 91 deletions(-) create mode 100644 src/AnthropicClient/IAnthropicApiClient.cs create mode 100644 src/AnthropicClient/Models/MessageBatchDeleteResponse.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchDeleteResponseTests.cs diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 13ceafe..826b735 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -8,97 +8,6 @@ using AnthropicClient.Utils; namespace AnthropicClient; -/// -/// Represents a client for interacting with the Anthropic API. -/// -public interface IAnthropicApiClient -{ - /// - /// Creates a message asynchronously. - /// - /// The message request to create. - /// A task that represents the asynchronous operation. The task result contains the response as an . - Task> CreateMessageAsync(MessageRequest request); - - /// - /// Creates a message asynchronously and streams the response. - /// - /// The message request to create. - /// An asynchronous enumerable that yields the response event by event. - IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request); - - /// - /// Creates a batch of messages asynchronously. - /// - /// The message batch request to create. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is . - Task> CreateMessageBatchAsync(MessageBatchRequest request); - - /// - /// Gets a message batch asynchronously. - /// - /// The ID of the message batch to get. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is . - Task> GetMessageBatchAsync(string batchId); - - /// - /// Lists the message batches asynchronously. - /// - /// The paging request to use for listing the message batches. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . - Task>> ListMessageBatchesAsync(PagingRequest? request = null); - - /// - /// Lists all message batches asynchronously. - /// - /// The maximum number of message batches to return in each page. - /// An asynchronous enumerable that yields the response as an where T is where T is . - IAsyncEnumerable>> ListAllMessageBatchesAsync(int limit = 20); - - /// - /// Cancels a message batch asynchronously. - /// - /// The ID of the message batch to cancel. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is . - Task> CancelMessageBatchAsync(string batchId); - - /// - /// Gets the results of a message batch asynchronously. - /// - /// The ID of the message batch to get the results for. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . - Task>> GetMessageBatchResultsAsync(string batchId); - - /// - /// Counts the tokens in a message asynchronously. - /// - /// The count message tokens request. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is . - Task> CountMessageTokensAsync(CountMessageTokensRequest request); - - /// - /// Lists the models asynchronously. - /// - /// The paging request to use for listing the models. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . - Task>> ListModelsAsync(PagingRequest? request = null); - - /// - /// Lists the models asynchronously - /// - /// The maximum number of models to return in each page. - /// An asynchronous enumerable that yields the response as an where T is where T is . - /// - IAsyncEnumerable>> ListAllModelsAsync(int limit = 20); - - /// - /// Gets a model by its ID asynchronously. - /// - /// The ID of the model to get. - /// A task that represents the asynchronous operation. The task result contains the response as an where T is . - Task> GetModelAsync(string modelId); -} - /// public class AnthropicApiClient : IAnthropicApiClient { @@ -369,6 +278,14 @@ public class AnthropicApiClient : IAnthropicApiClient return await CreateResultAsync(response); } + /// + public async Task> DeleteMessageBatchAsync(string batchId) + { + var endpoint = $"{MessageBatchesEndpoint}/{batchId}"; + var response = await SendRequestAsync(endpoint, HttpMethod.Delete); + return await CreateResultAsync(response); + } + /// public async Task>> GetMessageBatchResultsAsync(string batchId) { diff --git a/src/AnthropicClient/IAnthropicApiClient.cs b/src/AnthropicClient/IAnthropicApiClient.cs new file mode 100644 index 0000000..8081166 --- /dev/null +++ b/src/AnthropicClient/IAnthropicApiClient.cs @@ -0,0 +1,101 @@ +using AnthropicClient.Models; + +namespace AnthropicClient; + +/// +/// Represents a client for interacting with the Anthropic API. +/// +public interface IAnthropicApiClient +{ + /// + /// Creates a message asynchronously. + /// + /// The message request to create. + /// A task that represents the asynchronous operation. The task result contains the response as an . + Task> CreateMessageAsync(MessageRequest request); + + /// + /// Creates a message asynchronously and streams the response. + /// + /// The message request to create. + /// An asynchronous enumerable that yields the response event by event. + IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request); + + /// + /// Creates a batch of messages asynchronously. + /// + /// The message batch request to create. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> CreateMessageBatchAsync(MessageBatchRequest request); + + /// + /// Gets a message batch asynchronously. + /// + /// The ID of the message batch to get. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> GetMessageBatchAsync(string batchId); + + /// + /// Lists the message batches asynchronously. + /// + /// The paging request to use for listing the message batches. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . + Task>> ListMessageBatchesAsync(PagingRequest? request = null); + + /// + /// Lists all message batches asynchronously. + /// + /// The maximum number of message batches to return in each page. + /// An asynchronous enumerable that yields the response as an where T is where T is . + IAsyncEnumerable>> ListAllMessageBatchesAsync(int limit = 20); + + /// + /// Cancels a message batch asynchronously. + /// + /// The ID of the message batch to cancel. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> CancelMessageBatchAsync(string batchId); + + /// + /// Deletes a message batch asynchronously. + /// + /// The ID of the message batch to delete. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> DeleteMessageBatchAsync(string batchId); + + /// + /// Gets the results of a message batch asynchronously. + /// + /// The ID of the message batch to get the results for. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . + Task>> GetMessageBatchResultsAsync(string batchId); + + /// + /// Counts the tokens in a message asynchronously. + /// + /// The count message tokens request. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> CountMessageTokensAsync(CountMessageTokensRequest request); + + /// + /// Lists the models asynchronously. + /// + /// The paging request to use for listing the models. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is . + Task>> ListModelsAsync(PagingRequest? request = null); + + /// + /// Lists the models asynchronously + /// + /// The maximum number of models to return in each page. + /// An asynchronous enumerable that yields the response as an where T is where T is . + /// + IAsyncEnumerable>> ListAllModelsAsync(int limit = 20); + + /// + /// Gets a model by its ID asynchronously. + /// + /// The ID of the model to get. + /// A task that represents the asynchronous operation. The task result contains the response as an where T is . + Task> GetModelAsync(string modelId); +} diff --git a/src/AnthropicClient/Models/MessageBatchDeleteResponse.cs b/src/AnthropicClient/Models/MessageBatchDeleteResponse.cs new file mode 100644 index 0000000..877c808 --- /dev/null +++ b/src/AnthropicClient/Models/MessageBatchDeleteResponse.cs @@ -0,0 +1,17 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a message batch delete response. +/// +public class MessageBatchDeleteResponse +{ + /// + /// Gets the ID of the message batch that was deleted. + /// + public string Id { get; init; } = string.Empty; + + /// + /// Gets the type of the message batch response. + /// + public string Type { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 8b312c2..6cab022 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -1904,4 +1904,28 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Should().BeOfType(); result.Error.Error.Should().BeOfType(); } + + [Fact] + public async Task DeleteMessageBatchAsync_WhenCalled_ItShouldReturnDeletionResponse() + { + var batchId = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"; + + _mockHttpMessageHandler + .WhenDeleteMessageBatchRequest(batchId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"", + ""type"": ""message_batch_deleted"" + }" + ); + + var result = await Client.DeleteMessageBatchAsync(batchId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be(batchId); + result.Value.Type.Should().Be("message_batch_deleted"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index e521d0c..223107b 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -97,4 +97,10 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Post, $"{MessageBatchesEndpoint}/{batchId}/cancel"); } + + public static MockedRequest WhenDeleteMessageBatchRequest(this MockHttpMessageHandler mockHttpMessageHandler, string batchId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Delete, $"{MessageBatchesEndpoint}/{batchId}"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchDeleteResponseTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchDeleteResponseTests.cs new file mode 100644 index 0000000..a069ebd --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchDeleteResponseTests.cs @@ -0,0 +1,41 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageBatchDeleteResponseTests : SerializationTest +{ + private const string SampleJson = @"{ + ""id"": ""test-id"", + ""type"": ""test-type"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet() + { + var result = new MessageBatchDeleteResponse(); + + result.Id.Should().BeEmpty(); + result.Type.Should().BeEmpty(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var result = new MessageBatchDeleteResponse + { + Id = "test-id", + Type = "test-type" + }; + + var json = Serialize(result); + + JsonAssert.Equal(SampleJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedValues() + { + var result = Deserialize(SampleJson); + + result!.Id.Should().Be("test-id"); + result.Type.Should().Be("test-type"); + } +} \ No newline at end of file From 2794c52c2a411359381b84c90be056aa7b4c2f68 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 14 Jan 2025 13:52:46 -0600 Subject: [PATCH 19/22] docs: stub out batch documentation --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index dc0901f..293aebc 100644 --- a/README.md +++ b/README.md @@ -971,3 +971,37 @@ foreach (var content in response.Value.Content) } } ``` + +### Message Batches + +Anthropic provides a feature called [Message Batches](https://docs.anthropic.com/en/docs/build-with-claude/message-batches) that allows you to send multiple messages in a single request. This feature is covered in depth in [Anthropic's API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/message-batches). + +#### Create a message batch + +```csharp +``` + +#### Get a message batch + +```csharp +``` + +#### Get a message batch results + +```csharp +``` + +#### List message batches + +```csharp +``` + +#### Cancel a message batch + +```csharp +``` + +#### Delete a message batch + +```csharp +``` From 07cadf17af554c251e3a8bd49f0d31ab6c0b7c7c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 15 Jan 2025 10:23:59 -0600 Subject: [PATCH 20/22] docs: add examples for message batch api methods --- README.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/README.md b/README.md index 293aebc..696342b 100644 --- a/README.md +++ b/README.md @@ -978,30 +978,186 @@ Anthropic provides a feature called [Message Batches](https://docs.anthropic.com #### Create a message batch +You can create a message batch that will consist of one or more requests to create messages. + ```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var request = new MessageBatchRequest([ + new( + Guid.NewGuid().ToString(), + new( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ) + ), +]); + +var response = await client.CreateMessageBatchAsync(request); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to create message batch"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +Console.WriteLine("Message Batch Id: {0}", response.Value.Id); ``` #### Get a message batch +You can retrieve a message batch by its id. + ```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var response = await client.GetMessageBatchAsync("batch-id"); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to get message batch"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +Console.WriteLine("Message Batch Id: {0}", response.Value.Id); ``` #### Get a message batch results +You can retrieve the results of a message batch by its id. The results are returned as an `IAsyncEnumerable` collection so that they can be streamed and processed as they are received. + ```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var response = await client.GetMessageBatchResultsAsync("batch-id"); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to get message batch results"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +await foreach (var item in response.Value) +{ + Console.WriteLine("Item Custom Id: {0}", result.CustomId); + + switch (item.Result) + { + case SucceededMessageBatchResult successResult: + foreach (var content in successResult.Message.Content) + { + if (content is TextContent textContent) + { + Console.WriteLine("Message Batch Result: {0}", textContent.Text); + } + } + break; + default: + Console.WriteLine("Message Batch Result: {0}", item.Result.Type); + break; + } +} ``` #### List message batches +You can retrieve a page of message batches. + ```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var response = await client.ListMessageBatchesAsync(); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to list message batches"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +foreach (var batch in response.Value.Data) +{ + Console.WriteLine("Message Batch Id: {0}", batch.Id); +} +``` + +#### List all message batches + +You can also retrieve all the pages of message batches without having to implement pagination yourself. This is done by returning an `IAsyncEnumerable` collection that can be streamed and processed as the pages are received. + +```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var pageResponses = await client.ListAllMessageBatchesAsync(); + +await foreach (var response in pageResponses) +{ + if (response.IsFailure) + { + Console.WriteLine("Failed to list message batches"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; + } + + foreach (var batch in response.Value.Data) + { + Console.WriteLine("Message Batch Id: {0}", batch.Id); + } +} ``` #### Cancel a message batch +You can cancel a message batch by its id. + ```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var response = await client.CancelMessageBatchAsync("batch-id"); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to cancel message batch"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +Console.WriteLine("Message Batch Id: {0}", response.Value.Id); +Console.WriteLine("Message Batch Status: {0}", response.Value.ProcessingStatus); ``` #### Delete a message batch +You can delete a message batch that is no longer being processed by its id. + ```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var response = await client.DeleteMessageBatchAsync("batch-id"); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to delete message batch"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +Console.WriteLine("Message Batch Id: {0}", response.Value.Id); ``` From ee9dbcf647cdb72b13fc6964035b766fcd2a2898 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 15 Jan 2025 10:29:23 -0600 Subject: [PATCH 21/22] docs: fix example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 696342b..f23653a 100644 --- a/README.md +++ b/README.md @@ -1100,7 +1100,7 @@ You can also retrieve all the pages of message batches without having to impleme using AnthropicClient; using AnthropicClient.Models; -var pageResponses = await client.ListAllMessageBatchesAsync(); +var pageResponses = client.ListAllMessageBatchesAsync(); await foreach (var response in pageResponses) { From fc2932f3c51f28e89ddf11416cd5e707fee902a6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 15 Jan 2025 10:30:02 -0600 Subject: [PATCH 22/22] chore: run dotnet format --- src/AnthropicClient/IAnthropicApiClient.cs | 2 +- src/AnthropicClient/Models/CanceledMessageBatchResult.cs | 2 +- src/AnthropicClient/Models/ErroredMessageBatchResult.cs | 2 +- src/AnthropicClient/Models/MessageBatchResultItem.cs | 2 +- src/AnthropicClient/Models/MessageBatchResultType.cs | 2 +- src/AnthropicClient/Models/MessageBatchStatus.cs | 4 ++-- src/AnthropicClient/Models/SucceededMessageBatchResult.cs | 2 +- .../Unit/Models/MessageBatchStatusTests.cs | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/AnthropicClient/IAnthropicApiClient.cs b/src/AnthropicClient/IAnthropicApiClient.cs index 8081166..27c7e00 100644 --- a/src/AnthropicClient/IAnthropicApiClient.cs +++ b/src/AnthropicClient/IAnthropicApiClient.cs @@ -98,4 +98,4 @@ public interface IAnthropicApiClient /// The ID of the model to get. /// A task that represents the asynchronous operation. The task result contains the response as an where T is . Task> GetModelAsync(string modelId); -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CanceledMessageBatchResult.cs b/src/AnthropicClient/Models/CanceledMessageBatchResult.cs index 878bff6..e2f02f6 100644 --- a/src/AnthropicClient/Models/CanceledMessageBatchResult.cs +++ b/src/AnthropicClient/Models/CanceledMessageBatchResult.cs @@ -11,4 +11,4 @@ public class CanceledMessageBatchResult : MessageBatchResult public CanceledMessageBatchResult() : base(MessageBatchResultType.Canceled) { } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ErroredMessageBatchResult.cs b/src/AnthropicClient/Models/ErroredMessageBatchResult.cs index 4e7dbf5..467dc7e 100644 --- a/src/AnthropicClient/Models/ErroredMessageBatchResult.cs +++ b/src/AnthropicClient/Models/ErroredMessageBatchResult.cs @@ -16,4 +16,4 @@ public class ErroredMessageBatchResult : MessageBatchResult public ErroredMessageBatchResult() : base(MessageBatchResultType.Errored) { } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageBatchResultItem.cs b/src/AnthropicClient/Models/MessageBatchResultItem.cs index 14f9be6..5613619 100644 --- a/src/AnthropicClient/Models/MessageBatchResultItem.cs +++ b/src/AnthropicClient/Models/MessageBatchResultItem.cs @@ -17,4 +17,4 @@ public class MessageBatchResultItem /// Gets the result of the message batch result item. /// public MessageBatchResult Result { get; init; } = default!; -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageBatchResultType.cs b/src/AnthropicClient/Models/MessageBatchResultType.cs index 187c2c2..6f14964 100644 --- a/src/AnthropicClient/Models/MessageBatchResultType.cs +++ b/src/AnthropicClient/Models/MessageBatchResultType.cs @@ -24,4 +24,4 @@ public static class MessageBatchResultType /// Represents an expired message batch result. /// public const string Expired = "expired"; -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageBatchStatus.cs b/src/AnthropicClient/Models/MessageBatchStatus.cs index 5e1f299..6730674 100644 --- a/src/AnthropicClient/Models/MessageBatchStatus.cs +++ b/src/AnthropicClient/Models/MessageBatchStatus.cs @@ -9,12 +9,12 @@ public static class MessageBatchStatus /// The status of a message batch that is being canceled. /// public const string Canceling = "canceling"; - + /// /// The status of a message batch that is in progress. /// public const string InProgress = "in_progress"; - + /// /// The status of a message batch that has ended. /// diff --git a/src/AnthropicClient/Models/SucceededMessageBatchResult.cs b/src/AnthropicClient/Models/SucceededMessageBatchResult.cs index 82260e8..de8bcea 100644 --- a/src/AnthropicClient/Models/SucceededMessageBatchResult.cs +++ b/src/AnthropicClient/Models/SucceededMessageBatchResult.cs @@ -17,4 +17,4 @@ public class SucceededMessageBatchResult : MessageBatchResult public SucceededMessageBatchResult() : base(MessageBatchResultType.Succeeded) { } -} +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs index 1d66d45..316a403 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchStatusTests.cs @@ -7,13 +7,13 @@ public class MessageBatchStatusTests { MessageBatchStatus.Canceling.Should().Be("canceling"); } - + [Fact] public void InProgress_WhenCalled_ItShouldReturnCancelingStatus() { MessageBatchStatus.InProgress.Should().Be("in_progress"); } - + [Fact] public void Ended_WhenCalled_ItShouldReturnCancelingStatus() {