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