feat: implement GetMessageBatchResultsAsync method

This commit is contained in:
Stevan Freeborn
2025-01-09 23:17:41 -06:00
parent 0a6d89bd77
commit c56adf394c
16 changed files with 294 additions and 23 deletions
+39
View File
@@ -42,6 +42,13 @@ public interface IAnthropicApiClient
/// <returns>A task that represents the asynchronous operation. The task result contains the response as an <see cref="AnthropicResult{T}"/> where T is <see cref="MessageBatchResponse"/>.</returns>
Task<AnthropicResult<MessageBatchResponse>> GetMessageBatchAsync(string batchId);
/// <summary>
/// Gets the results of a message batch asynchronously.
/// </summary>
/// <param name="batchId">The ID of the message batch to get the results for.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the response as an <see cref="AnthropicResult{T}"/> where T is <see cref="IAsyncEnumerable{T}"/> where T is <see cref="MessageBatchResultItem"/>.</returns>
Task<AnthropicResult<IAsyncEnumerable<MessageBatchResultItem>>> GetMessageBatchResultsAsync(string batchId);
/// <summary>
/// Counts the tokens in a message asynchronously.
/// </summary>
@@ -336,6 +343,38 @@ public class AnthropicApiClient : IAnthropicApiClient
return AnthropicResult<MessageBatchResponse>.Success(msgBatchResponse, anthropicHeaders);
}
/// <inheritdoc/>
public async Task<AnthropicResult<IAsyncEnumerable<MessageBatchResultItem>>> 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<AnthropicError>(content) ?? new AnthropicError();
return AnthropicResult<IAsyncEnumerable<MessageBatchResultItem>>.Failure(error, anthropicHeaders);
}
async IAsyncEnumerable<MessageBatchResultItem> 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<MessageBatchResultItem>(line) ?? new MessageBatchResultItem();
yield return resultItem;
line = await streamReader.ReadLineAsync();
}
}
return AnthropicResult<IAsyncEnumerable<MessageBatchResultItem>>.Success(ReadResults(), anthropicHeaders);
}
/// <inheritdoc/>
public async Task<AnthropicResult<TokenCountResponse>> CountMessageTokensAsync(CountMessageTokensRequest request)
{
@@ -17,6 +17,7 @@ static class JsonSerializationOptions
new EventDataConverter(),
new ContentDeltaConverter(),
new JsonStringEnumConverter(),
new MessageBatchResultConverter(),
},
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AnthropicClient.Models;
namespace AnthropicClient.Json;
class MessageBatchResultConverter : JsonConverter<MessageBatchResult>
{
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<SucceededMessageBatchResult>(root.GetRawText(), options)!,
MessageBatchResultType.Errored => JsonSerializer.Deserialize<ErroredMessageBatchResult>(root.GetRawText(), options)!,
MessageBatchResultType.Canceled => JsonSerializer.Deserialize<CanceledMessageBatchResult>(root.GetRawText(), options)!,
MessageBatchResultType.Expired => JsonSerializer.Deserialize<ExpiredMessageBatchResult>(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);
}
}
@@ -0,0 +1,14 @@
namespace AnthropicClient.Models;
/// <summary>
/// Represents a message batch result that was cancelled.
/// </summary>
public class CanceledMessageBatchResult : MessageBatchResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CanceledMessageBatchResult"/> class.
/// </summary>
public CanceledMessageBatchResult() : base(MessageBatchResultType.Canceled)
{
}
}
@@ -0,0 +1,19 @@
namespace AnthropicClient.Models;
/// <summary>
/// Represents a message batch result that contains an error response.
/// </summary>
public class ErroredMessageBatchResult : MessageBatchResult
{
/// <summary>
/// Gets the error of the message batch result.
/// </summary>
public AnthropicError Error { get; init; } = new AnthropicError();
/// <summary>
/// Initializes a new instance of the <see cref="ErroredMessageBatchResult"/> class.
/// </summary>
public ErroredMessageBatchResult() : base(MessageBatchResultType.Errored)
{
}
}
@@ -0,0 +1,14 @@
namespace AnthropicClient.Models;
/// <summary>
/// Represents a message batch result that has expired.
/// </summary>
public class ExpiredMessageBatchResult : MessageBatchResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ExpiredMessageBatchResult"/> class.
/// </summary>
public ExpiredMessageBatchResult() : base(MessageBatchResultType.Expired)
{
}
}
@@ -0,0 +1,26 @@
using AnthropicClient.Utils;
namespace AnthropicClient.Models;
/// <summary>
/// Represents a message batch result.
/// </summary>
public abstract class MessageBatchResult
{
/// <summary>
/// Gets the type of the message batch result.
/// </summary>
public string Type { get; init; } = string.Empty;
/// <summary>
/// Initializes a new instance of the <see cref="MessageBatchResult"/> class.
/// </summary>
/// <param name="type">The type of the message batch result.</param>
/// <returns>An instance of the <see cref="MessageBatchResult"/> class.</returns>
public MessageBatchResult(string type)
{
ArgumentValidator.ThrowIfNullOrWhitespace(type, nameof(type));
Type = type;
}
}
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
/// <summary>
/// Represents a message batch result item.
/// </summary>
public class MessageBatchResultItem
{
/// <summary>
/// Gets the custom ID of the message batch result item.
/// </summary>
[JsonPropertyName("custom_id")]
public string CustomId { get; init; } = string.Empty;
/// <summary>
/// Gets the result of the message batch result item.
/// </summary>
public MessageBatchResult Result { get; init; } = default!;
}
@@ -0,0 +1,27 @@
namespace AnthropicClient.Models;
/// <summary>
/// Represents the types of message batch results.
/// </summary>
public static class MessageBatchResultType
{
/// <summary>
/// Represents a succeeded message batch result.
/// </summary>
public const string Succeeded = "succeeded";
/// <summary>
/// Represents an errored message batch result.
/// </summary>
public const string Errored = "errored";
/// <summary>
/// Represents a canceled message batch result.
/// </summary>
public const string Canceled = "canceled";
/// <summary>
/// Represents an expired message batch result.
/// </summary>
public const string Expired = "expired";
}
@@ -0,0 +1,20 @@
namespace AnthropicClient.Models;
/// <summary>
/// Represents a message batch result that contains a message response.
/// </summary>
public class SucceededMessageBatchResult : MessageBatchResult
{
/// <summary>
/// Gets the message of the message batch result.
/// </summary>
public MessageResponse Message { get; init; } = new MessageResponse();
/// <summary>
/// Initializes a new instance of the <see cref="SucceededMessageBatchResult"/> class.
/// </summary>
/// <returns>An instance of the <see cref="SucceededMessageBatchResult"/> class.</returns>
public SucceededMessageBatchResult() : base(MessageBatchResultType.Succeeded)
{
}
}
@@ -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);
@@ -0,0 +1,7 @@
namespace AnthropicClient.Tests.Files;
static class TestFileHelper
{
public static string GetTestFilePath(string fileName) =>
Path.Combine(Directory.GetCurrentDirectory(), "Files", fileName);
}
@@ -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}}}}
@@ -1,3 +1,6 @@
using AnthropicClient.Tests.Files;
using AnthropicClient.Tests.Unit;
namespace AnthropicClient.Tests.Integration;
public class AnthropicApiClientTests : IntegrationTest
@@ -1218,4 +1221,30 @@ public class AnthropicApiClientTests : IntegrationTest
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeOfType<MessageBatchResponse>();
}
[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<MessageBatchResultItem>);
_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);
}
}
@@ -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();
@@ -77,4 +79,10 @@ public static class MockHttpMessageHandlerExtensions
return mockHttpMessageHandler
.SetupBaseRequest(HttpMethod.Get, $"{MessageBatchesEndpoint}/{batchId}");
}
public static MockedRequest WhenGetMessageBatchResultsRequest(this MockHttpMessageHandler mockHttpMessageHandler, string batchId)
{
return mockHttpMessageHandler
.SetupBaseRequest(HttpMethod.Get, $"{MessageBatchesEndpoint}/{batchId}/results");
}
}
@@ -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<MessageBatchResultItem>();
result.CustomId.Should().BeEmpty();
result.Result.Should().Be(default);
}
}