diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 9a92093..836a6bd 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Threading.Tasks; using AnthropicClient.Json; using AnthropicClient.Models; @@ -39,15 +40,15 @@ public interface IAnthropicApiClient /// /// 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); + Task>> ListModelsAsync(PagingRequest? request = null); /// - /// Lists the models asynchronously. + /// 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>> ListModelsAsync(int limit = 20); + IAsyncEnumerable>> ListAllModelsAsync(int limit = 20); } /// @@ -57,6 +58,7 @@ public class AnthropicApiClient : IAnthropicApiClient private const string ApiKeyHeader = "x-api-key"; private const string MessagesEndpoint = "messages"; private const string CountTokensEndpoint = "messages/count_tokens"; + private const string ModelsEndpoint = "models"; private const string JsonContentType = "application/json"; private const string EventPrefix = "event:"; private const string DataPrefix = "data:"; @@ -292,10 +294,64 @@ public class AnthropicApiClient : IAnthropicApiClient } var msgResponse = Deserialize(responseContent) ?? new TokenCountResponse(); - return AnthropicResult.Success(msgResponse, anthropicHeaders); } + /// + public async Task>> ListModelsAsync(PagingRequest? request = null) + { + 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); + } + + /// + public async IAsyncEnumerable>> ListAllModelsAsync(int limit = 20) + { + var pagingRequest = new PagingRequest(limit: limit); + string Endpoint() => $"{ModelsEndpoint}?{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(); @@ -315,6 +371,11 @@ public class AnthropicApiClient : IAnthropicApiClient return new ToolCall(tool, toolUse); } + private async Task SendRequestAsync(string endpoint) + { + return await _httpClient.GetAsync(endpoint); + } + private async Task SendRequestAsync(string endpoint, T request) { var requestJson = Serialize(request); @@ -324,16 +385,4 @@ public class AnthropicApiClient : IAnthropicApiClient private string Serialize(T obj) => JsonSerializer.Serialize(obj, JsonSerializationOptions.DefaultOptions); private T? Deserialize(string json) => JsonSerializer.Deserialize(json, JsonSerializationOptions.DefaultOptions); - - /// - public Task>> ListModelsAsync(PagingRequest request) - { - throw new NotImplementedException(); - } - - /// - public IAsyncEnumerable>> ListModelsAsync(int limit = 20) - { - throw new NotImplementedException(); - } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/Page.cs b/src/AnthropicClient/Models/Page.cs index e0ec5a8..729d90b 100644 --- a/src/AnthropicClient/Models/Page.cs +++ b/src/AnthropicClient/Models/Page.cs @@ -11,13 +11,13 @@ public class Page /// The id of the first item in the page. /// [JsonPropertyName("first_id")] - public string FirstId { get; init; } = string.Empty; + public string? FirstId { get; init; } = string.Empty; /// /// The id of the last item in the page. /// [JsonPropertyName("last_id")] - public string LastId { get; init; } = string.Empty; + public string? LastId { get; init; } = string.Empty; /// /// Indicates whether there is more data to be retrieved. diff --git a/src/AnthropicClient/Models/PagingRequest.cs b/src/AnthropicClient/Models/PagingRequest.cs index b7aad83..f8b415a 100644 --- a/src/AnthropicClient/Models/PagingRequest.cs +++ b/src/AnthropicClient/Models/PagingRequest.cs @@ -70,10 +70,7 @@ public class PagingRequest parameters.Add($"after_id={AfterId}"); } - if (Limit is not DefaultLimit) - { - parameters.Add($"limit={Limit}"); - } + parameters.Add($"limit={Limit}"); return string.Join("&", parameters); } diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 50dab64..e64893a 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -303,4 +303,32 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf result.Value.Should().BeOfType(); result.Value.InputTokens.Should().BeGreaterThan(0); } + + [Fact] + public async Task ListModelsAsync_WhenCalled_ItShouldReturnResponse() + { + var result = await _client.ListModelsAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.Data.Should().HaveCountGreaterThan(0); + } + + [Fact] + public async Task ListModelsAsync_WhenCalledWithPagination_ItShouldReturnResponse() + { + var result = await _client.ListModelsAsync(new PagingRequest(limit: 1)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.Data.Should().HaveCount(1); + } + + [Fact] + public async Task ListAllModelsAsync_WhenCalled_ItShouldReturnResponse() + { + var responses = await _client.ListAllModelsAsync(limit: 1).ToListAsync(); + + responses.Should().HaveCountGreaterThan(0); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index f874a31..795b132 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -57,7 +57,7 @@ public class AnthropicApiClientTests : IntegrationTest ""input_tokens"": 10, ""output_tokens"": 25 } - }" + }" ); var request = new MessageRequest( @@ -115,7 +115,7 @@ public class AnthropicApiClientTests : IntegrationTest ""input_tokens"": 10, ""output_tokens"": 25 } - }" + }" ); var func = (string ticker) => ticker; @@ -191,7 +191,7 @@ public class AnthropicApiClientTests : IntegrationTest ""input_tokens"": 10, ""output_tokens"": 25 } - }" + }" ); var request = new MessageRequest( @@ -331,12 +331,12 @@ public class AnthropicApiClientTests : IntegrationTest 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"" - } - }" + ""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 StreamMessageRequest( @@ -385,7 +385,7 @@ public class AnthropicApiClientTests : IntegrationTest ""input_tokens"": 10, ""output_tokens"": 25 } - }" + }" ); var request = new MessageRequest( @@ -428,8 +428,8 @@ public class AnthropicApiClientTests : IntegrationTest HttpStatusCode.OK, "application/json", @"{ - ""input_tokens"": 10 - }" + ""input_tokens"": 10 + }" ); var request = new CountMessageTokensRequest( @@ -455,12 +455,12 @@ public class AnthropicApiClientTests : IntegrationTest 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"" - } - }" + ""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 CountMessageTokensRequest( @@ -503,4 +503,387 @@ public class AnthropicApiClientTests : IntegrationTest result.Error.Should().BeOfType(); result.Error.Error.Should().BeOfType(); } + + [Fact] + public async Task ListModelsAsync_WhenCalledWithPagingRequestUsingDefaultValues_ItShouldReturnListOfModels() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""type"": ""model"", + ""id"": ""claude-3-5-sonnet-20241022"", + ""display_name"": ""Claude 3.5 Sonnet (New)"", + ""created_at"": ""2024-10-22T00:00:00Z"" + } + ], + ""has_more"": true, + ""first_id"": ""first_id"", + ""last_id"": ""last_id"" + }" + ); + + var result = await Client.ListModelsAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.HasMore.Should().BeTrue(); + result.Value.FirstId.Should().Be("first_id"); + result.Value.LastId.Should().Be("last_id"); + result.Value.Data.Should().BeEquivalentTo(new AnthropicModel[] + { + new() + { + Type = "model", + Id = "claude-3-5-sonnet-20241022", + DisplayName = "Claude 3.5 Sonnet (New)", + CreatedAt = DateTimeOffset.Parse("2024-10-22T00:00:00Z") + } + }); + } + + [Fact] + public async Task ListModelsAsync_WhenCalledWithPagingRequestUsingCustomValues_ItShouldReturnListOfModels() + { + var pagingRequest = new PagingRequest("prev_id", "next_id", 10); + + _mockHttpMessageHandler + .WhenListModelsRequest() + .WithQueryString(new Dictionary + { + { "before_id", pagingRequest.BeforeId }, + { "after_id", pagingRequest.AfterId }, + { "limit", pagingRequest.Limit.ToString() }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""type"": ""model"", + ""id"": ""claude-3-5-sonnet-20241022"", + ""display_name"": ""Claude 3.5 Sonnet (New)"", + ""created_at"": ""2024-10-22T00:00:00Z"" + } + ], + ""has_more"": true, + ""first_id"": ""first_id"", + ""last_id"": ""last_id"" + }" + ); + + var result = await Client.ListModelsAsync(pagingRequest); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType>(); + result.Value.HasMore.Should().BeTrue(); + result.Value.FirstId.Should().Be("first_id"); + result.Value.LastId.Should().Be("last_id"); + result.Value.Data.Should().BeEquivalentTo(new AnthropicModel[] + { + new() + { + Type = "model", + Id = "claude-3-5-sonnet-20241022", + DisplayName = "Claude 3.5 Sonnet (New)", + CreatedAt = DateTimeOffset.Parse("2024-10-22T00:00:00Z") + } + }); + } + + [Fact] + public async Task ListModelsAsync_WhenCalledAndNoModelsReturned_ItShouldReturnEmptyList() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [], + ""has_more"": false, + ""first_id"": null, + ""last_id"": null + }" + ); + + var result = await Client.ListModelsAsync(); + + 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 ListModelsAsync_WhenCalledAndErrorReturned_ItShouldHandleError() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .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.ListModelsAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task ListModelsAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{}" + ); + + var result = await Client.ListModelsAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task ListAllModelsAsync_WhenCalled_ItShouldReturnAllModels() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .WithExactQueryString(new Dictionary() + { + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""type"": ""model"", + ""id"": ""claude-3-5-sonnet-20241022"", + ""display_name"": ""Claude 3.5 Sonnet (New)"", + ""created_at"": ""2024-10-22T00:00:00Z"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + _mockHttpMessageHandler + .WhenListModelsRequest() + .WithExactQueryString(new Dictionary() + { + { "after_id", "1" }, + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""type"": ""model"", + ""id"": ""claude-3-5-sonnet-20241023"", + ""display_name"": ""Claude 3.5 Sonnet (New)"", + ""created_at"": ""2024-10-23T00:00:00Z"" + } + ], + ""has_more"": false, + ""first_id"": ""2"", + ""last_id"": ""2"" + }" + ); + + var pageResponses = Client.ListAllModelsAsync(); + var collectedPages = new List>(); + + await foreach (var response in pageResponses) + { + response.IsSuccess.Should().BeTrue(); + response.Value.Should().BeOfType>(); + collectedPages.Add(response.Value); + } + + collectedPages.Should().HaveCount(2); + collectedPages.Should().BeEquivalentTo(new Page[] + { + new() + { + HasMore = true, + FirstId = "1", + LastId = "1", + Data = [ + new() + { + Type = "model", + Id = "claude-3-5-sonnet-20241022", + DisplayName = "Claude 3.5 Sonnet (New)", + CreatedAt = DateTimeOffset.Parse("2024-10-22T00:00:00Z") + } + ] + }, + new() + { + HasMore = false, + FirstId = "2", + LastId = "2", + Data = [ + new() + { + Type = "model", + Id = "claude-3-5-sonnet-20241023", + DisplayName = "Claude 3.5 Sonnet (New)", + CreatedAt = DateTimeOffset.Parse("2024-10-23T00:00:00Z") + } + ] + } + }); + } + + [Fact] + public async Task ListAllModelsAsync_WhenCalledAndErrorReturned_ItShouldHandleError() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .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 responses = Client.ListAllModelsAsync(); + var count = 0; + + await foreach (var page in responses) + { + count++; + page.IsSuccess.Should().BeFalse(); + page.Error.Should().BeOfType(); + page.Error.Error.Should().BeOfType(); + } + + count.Should().Be(1); + } + + [Fact] + public async Task ListAllModelsAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{}" + ); + + var responses = Client.ListAllModelsAsync(); + var count = 0; + + await foreach (var page in responses) + { + count++; + page.IsSuccess.Should().BeFalse(); + page.Error.Should().BeOfType(); + page.Error.Error.Should().BeOfType(); + } + + count.Should().Be(1); + } + + [Fact] + public async Task ListAllModelsAsync_WhenFirstPageSucceedsAndSecondPageFails_ItShouldReturnFirstPageAndError() + { + _mockHttpMessageHandler + .WhenListModelsRequest() + .WithExactQueryString(new Dictionary + { + { "limit", "20" }, + }) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""data"": [ + { + ""type"": ""model"", + ""id"": ""claude-3-5-sonnet-20241022"", + ""display_name"": ""Claude 3.5 Sonnet (New)"", + ""created_at"": ""2024-10-22T00:00:00Z"" + } + ], + ""has_more"": true, + ""first_id"": ""1"", + ""last_id"": ""1"" + }" + ); + + _mockHttpMessageHandler + .WhenListModelsRequest() + .WithExactQueryString(new Dictionary + { + { "after_id", "1" }, + { "limit", "20" }, + }) + .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 responses = Client.ListAllModelsAsync(); + var count = 0; + + await foreach (var page in responses) + { + count++; + + if (count == 1) + { + page.IsSuccess.Should().BeTrue(); + page.Value.Should().BeOfType>(); + } + else + { + page.IsSuccess.Should().BeFalse(); + page.Error.Should().BeOfType(); + page.Error.Error.Should().BeOfType(); + } + } + + count.Should().Be(2); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index fd04cec..1d5b77a 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 ModelsEndpoint = $"{BaseUrl}/models"; private static MockedRequest SetupBaseRequest( this MockHttpMessageHandler mockHttpMessageHandler, @@ -51,4 +52,10 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Post, CountTokensEndpoint); } + + public static MockedRequest WhenListModelsRequest(this MockHttpMessageHandler mockHttpMessageHandler) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, ModelsEndpoint); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs index 54e6c8f..069120c 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs @@ -25,7 +25,7 @@ public class PagingRequestTests : SerializationTest var result = pagingRequest.ToQueryParameters(); - result.Should().BeEmpty(); + result.Should().Be("limit=20"); } [Fact] @@ -35,7 +35,7 @@ public class PagingRequestTests : SerializationTest var result = pagingRequest.ToQueryParameters(); - result.Should().Be("before_id=before-id"); + result.Should().Be("before_id=before-id&limit=20"); } [Fact] @@ -45,7 +45,7 @@ public class PagingRequestTests : SerializationTest var result = pagingRequest.ToQueryParameters(); - result.Should().Be("after_id=after-id"); + result.Should().Be("after_id=after-id&limit=20"); } [Fact]