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