diff --git a/.vscode/settings.json b/.vscode/settings.json index 211b47a..b2f391d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "editor.formatOnSave": true, "dotnet.defaultSolution": "AnthropicClient.sln", "cSpell.words": [ "Browsable", diff --git a/README.md b/README.md index a05c5fe..dc0901f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,73 @@ if (response.IsFailure) Console.WriteLine("Token Count: {0}", response.Value.InputTokens); ``` +### List Models + +The `AnthropicApiClient` exposes a method named `ListModelsAsync` that can be used to list the available models. The method takes an optional `PagingRequest` instance as a parameter. + +```csharp +using AnthropicClient; + +var response = await client.ListModelsAsync(); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to list models"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +foreach (var model in response.Value.Data) +{ + Console.WriteLine("Model Id: {0}", model.Id); + Console.WriteLine("Model Name: {0}", model.DisplayName); +} +``` + +Using the `PagingRequest` instance allows you to specify the number of models to return and the page of models to return. + +```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var response = await client.ListModelsAsync(new PagingRequest(afterId: "claude-3-5-sonnet-20241022", limit: 2)); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to list models"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +foreach (var model in response.Value.Data) +{ + Console.WriteLine("Model Id: {0}", model.Id); + Console.WriteLine("Model Name: {0}", model.DisplayName); +} +``` + +### Get Model + +The `AnthropicApiClient` exposes a method named `GetModelAsync` that can be used to get a model by its id. + +```csharp +using AnthropicClient; + +var response = await client.GetModelAsync("claude-3-5-sonnet-20241022"); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to get model"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +Console.WriteLine("Model Id: {0}", response.Value.Id); +``` + ### Create a message The `AnthropicApiClient` exposes a method named `CreateMessageAsync` that can be used to create a message. The method requires a `MessageRequest` or a `StreamMessageRequest` instance as a parameter. The `MessageRequest` class is used to create a message whose response is not streamed and the `StreamMessageRequest` class is used to create a message whose response is streamed. The `MessageRequest` instance's properties can be set to configure how the message is created. diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 48ea518..3be31ee 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; @@ -33,6 +34,28 @@ public interface IAnthropicApiClient /// 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); } /// @@ -42,6 +65,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:"; @@ -76,7 +100,7 @@ public class AnthropicApiClient : IAnthropicApiClient } } - /// + /// public async Task> CreateMessageAsync(MessageRequest request) { var response = await SendRequestAsync(MessagesEndpoint, request); @@ -99,7 +123,7 @@ public class AnthropicApiClient : IAnthropicApiClient return AnthropicResult.Success(msgResponse, anthropicHeaders); } - /// + /// public async IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request) { var response = await SendRequestAsync(MessagesEndpoint, request); @@ -263,7 +287,7 @@ public class AnthropicApiClient : IAnthropicApiClient } while (true); } - /// + /// public async Task> CountMessageTokensAsync(CountMessageTokensRequest request) { var response = await SendRequestAsync(CountTokensEndpoint, request); @@ -277,10 +301,82 @@ 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); + } + + /// + public async Task> GetModelAsync(string modelId) + { + 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); + } + private ToolCall? GetToolCall(MessageResponse response, List tools) { var toolUse = response.Content.OfType().FirstOrDefault(); @@ -300,6 +396,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); diff --git a/src/AnthropicClient/Models/AnthropicModel.cs b/src/AnthropicClient/Models/AnthropicModel.cs new file mode 100644 index 0000000..7a398f2 --- /dev/null +++ b/src/AnthropicClient/Models/AnthropicModel.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents an Anthropic model. +/// +public class AnthropicModel +{ + /// + /// The type of the model. + /// + public string Type { get; init; } = string.Empty; + + /// + /// The id of the model. + /// + public string Id { get; init; } = string.Empty; + + /// + /// The display name of the model. + /// + [JsonPropertyName("display_name")] + public string DisplayName { get; init; } = string.Empty; + + /// + /// The created date of the model. + /// + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; init; } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/Page.cs b/src/AnthropicClient/Models/Page.cs new file mode 100644 index 0000000..729d90b --- /dev/null +++ b/src/AnthropicClient/Models/Page.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a page. +/// +public class Page +{ + /// + /// The id of the first item in the page. + /// + [JsonPropertyName("first_id")] + 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; + + /// + /// Indicates whether there is more data to be retrieved. + /// + [JsonPropertyName("has_more")] + public bool HasMore { get; init; } +} + +/// +/// Represents a page with data. +/// +public class Page : Page +{ + /// + /// The data in the page. + /// + public T[] Data { get; init; } = []; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/PagingRequest.cs b/src/AnthropicClient/Models/PagingRequest.cs new file mode 100644 index 0000000..52746df --- /dev/null +++ b/src/AnthropicClient/Models/PagingRequest.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a request to page through a collection of items. +/// +public class PagingRequest +{ + private const int LimitMinimum = 1; + private const int LimitMaximum = 1000; + private const int DefaultLimit = 20; + + /// + /// The ID of the item before which to start the page. + /// + [JsonPropertyName("before_id")] + public string BeforeId { get; init; } + + /// + /// The ID of the item after which to start the page. + /// + [JsonPropertyName("after_id")] + public string AfterId { get; init; } + + /// + /// The maximum number of items to return in the page. + /// + public int Limit { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the item before which to start the page. + /// The ID of the item after which to start the page. + /// The maximum number of items to return in the page. + /// Thrown when both and are specified. + /// Thrown when is less than 1 or greater than 1000. + /// A new instance of the class. + public PagingRequest( + string beforeId = "", + string afterId = "", + int limit = DefaultLimit + ) + { + if (limit is < LimitMinimum or > LimitMaximum) + { + throw new ArgumentOutOfRangeException(nameof(limit), $"{nameof(limit)} must be between {LimitMinimum} and {LimitMaximum}."); + } + + if (string.IsNullOrEmpty(beforeId) is false && string.IsNullOrEmpty(afterId) is false) + { + throw new ArgumentException($"Only one of {nameof(beforeId)} or {nameof(afterId)} can be set."); + } + + BeforeId = beforeId; + AfterId = afterId; + Limit = limit; + } + + /// + /// Converts the to a query string. + /// + /// The query string representation of the . + public string ToQueryParameters() + { + var parameters = new List(); + + if (string.IsNullOrEmpty(BeforeId) is false) + { + parameters.Add($"before_id={BeforeId}"); + } + + if (string.IsNullOrEmpty(AfterId) is false) + { + parameters.Add($"after_id={AfterId}"); + } + + parameters.Add($"limit={Limit}"); + + return string.Join("&", parameters); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 50dab64..47ccadd 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -303,4 +303,42 @@ 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); + } + + [Fact] + public async Task GetModelAsync_WhenCalled_ItShouldReturnResponse() + { + var result = await _client.GetModelAsync(AnthropicModels.Claude3Haiku); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Id.Should().Be(AnthropicModels.Claude3Haiku); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index f874a31..eaef353 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( @@ -479,7 +479,7 @@ public class AnthropicApiClientTests : IntegrationTest } [Fact] - public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() + public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() { _mockHttpMessageHandler .WhenCountMessageTokensRequest() @@ -503,4 +503,479 @@ 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(afterId: "next_id", limit: 10); + + _mockHttpMessageHandler + .WhenListModelsRequest() + .WithQueryString(new Dictionary + { + { "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_WhenCalledRequestFailsAndCanNotDeserializeError_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_WhenCalledRequestFailsAndCanNotDeserializeError_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); + } + + [Fact] + public async Task GetModelAsync_WhenCalled_ItShouldReturnModel() + { + var modelId = "claude-3-5-sonnet-20241022"; + + _mockHttpMessageHandler + .WhenGetModelRequest(modelId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""type"": ""model"", + ""id"": ""claude-3-5-sonnet-20241022"", + ""display_name"": ""Claude 3.5 Sonnet (New)"", + ""created_at"": ""2024-10-22T00:00:00Z"" + }" + ); + + var result = await Client.GetModelAsync(modelId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.Type.Should().Be("model"); + result.Value.Id.Should().Be("claude-3-5-sonnet-20241022"); + result.Value.DisplayName.Should().Be("Claude 3.5 Sonnet (New)"); + result.Value.CreatedAt.Should().Be(DateTimeOffset.Parse("2024-10-22T00:00:00Z")); + } + + [Fact] + public async Task GetModelAsync_WhenCalledAndErrorReturned_ItShouldHandleError() + { + var modelId = "claude-3-5-sonnet-20241022"; + + _mockHttpMessageHandler + .WhenGetModelRequest(modelId) + .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.GetModelAsync(modelId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task GetModelAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() + { + var modelId = "claude-3-5-sonnet-20241022"; + + _mockHttpMessageHandler + .WhenGetModelRequest(modelId) + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{}" + ); + + var result = await Client.GetModelAsync(modelId); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task GetModelAsync_WhenCalledAndCanNotDeserializeModel_ItShouldReturnEmptyModel() + { + var modelId = "claude-3-5-sonnet-20241022"; + + _mockHttpMessageHandler + .WhenGetModelRequest(modelId) + .Respond( + HttpStatusCode.OK, + "application/json", + @"{}" + ); + + var result = await Client.GetModelAsync(modelId); + + 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 fd04cec..6b6376d 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,16 @@ public static class MockHttpMessageHandlerExtensions return mockHttpMessageHandler .SetupBaseRequest(HttpMethod.Post, CountTokensEndpoint); } + + public static MockedRequest WhenListModelsRequest(this MockHttpMessageHandler mockHttpMessageHandler) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, ModelsEndpoint); + } + + public static MockedRequest WhenGetModelRequest(this MockHttpMessageHandler mockHttpMessageHandler, string modelId) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Get, $"{ModelsEndpoint}/{modelId}"); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelTests.cs new file mode 100644 index 0000000..b7d1662 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelTests.cs @@ -0,0 +1,57 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AnthropicModelTests : SerializationTest +{ + private const string SampleJson = @"{ + ""type"": ""model"", + ""id"": ""claude-3-opus-20240229"", + ""display_name"": ""Claude 3 Opus"", + ""created_at"": ""2024-02-29T00:00:00Z"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet() + { + var model = new AnthropicModel(); + + model.Type.Should().BeEmpty(); + model.Id.Should().BeEmpty(); + model.DisplayName.Should().BeEmpty(); + model.CreatedAt.Should().Be(default); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedValues() + { + var result = Deserialize(SampleJson); + + result.Should().NotBeNull(); + result!.Type.Should().Be("model"); + result.Id.Should().Be("claude-3-opus-20240229"); + result.DisplayName.Should().Be("Claude 3 Opus"); + result.CreatedAt.Should().Be(new DateTimeOffset(2024, 2, 29, 0, 0, 0, TimeSpan.Zero)); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldReturnExpectedShape() + { + var model = new AnthropicModel + { + Type = "model", + Id = "claude-3-opus-20240229", + DisplayName = "Claude 3 Opus", + CreatedAt = new DateTimeOffset(2024, 2, 29, 0, 0, 0, TimeSpan.Zero) + }; + + var result = Serialize(model); + + var expectedJson = @"{ + ""type"": ""model"", + ""id"": ""claude-3-opus-20240229"", + ""display_name"": ""Claude 3 Opus"", + ""created_at"": ""2024-02-29T00:00:00+00:00"" + }"; + + JsonAssert.Equal(expectedJson, result); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/PageTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PageTests.cs new file mode 100644 index 0000000..61a2780 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/PageTests.cs @@ -0,0 +1,111 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class PageTests : SerializationTest +{ + private const string BasePageJson = @"{ + ""first_id"": ""msg_123"", + ""last_id"": ""msg_456"", + ""has_more"": true + }"; + + private const string GenericPageJson = @"{ + ""first_id"": ""msg_123"", + ""last_id"": ""msg_456"", + ""has_more"": true, + ""data"": [""item1"", ""item2""] + }"; + + private const string EmptyPageJson = @"{ + ""first_id"": """", + ""last_id"": """", + ""has_more"": false, + ""data"": [] + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet() + { + var page = new Page(); + + page.FirstId.Should().BeEmpty(); + page.LastId.Should().BeEmpty(); + page.HasMore.Should().BeFalse(); + } + + [Fact] + public void Constructor_WhenCalledWithGeneric_ItShouldReturnAnInstanceWithPropertiesSet() + { + var page = new Page(); + + page.FirstId.Should().BeEmpty(); + page.LastId.Should().BeEmpty(); + page.HasMore.Should().BeFalse(); + page.Data.Should().BeEmpty(); + } + + [Fact] + public void JsonDeserialization_WhenCalled_ItShouldHaveCorrectValues() + { + var result = Deserialize(BasePageJson); + + result.Should().NotBeNull(); + result!.FirstId.Should().Be("msg_123"); + result.LastId.Should().Be("msg_456"); + result.HasMore.Should().BeTrue(); + } + + [Fact] + public void JsonSerialization_WhenCalled_ItShouldHaveExpectedShape() + { + var page = new Page + { + FirstId = "msg_123", + LastId = "msg_456", + HasMore = true + }; + + var result = Serialize(page); + + JsonAssert.Equal(BasePageJson, result); + } + + [Fact] + public void JsonDeserialization_WhenCalledWithData_ItShouldHaveCorrectValues() + { + var result = Deserialize>(GenericPageJson); + + result.Should().NotBeNull(); + result!.FirstId.Should().Be("msg_123"); + result.LastId.Should().Be("msg_456"); + result.HasMore.Should().BeTrue(); + result.Data.Should().BeEquivalentTo(["item1", "item2"]); + } + + [Fact] + public void JsonSerialization_WhenCalledWithData_ItShouldHaveExpectedShape() + { + var page = new Page + { + FirstId = "msg_123", + LastId = "msg_456", + HasMore = true, + Data = ["item1", "item2"] + }; + + var result = Serialize(page); + + JsonAssert.Equal(GenericPageJson, result); + } + + [Fact] + public void JsonDeserialization_WhenCalledWithEmptyData_ItShouldHaveCorrectValues() + { + var result = Deserialize>(EmptyPageJson); + + result.Should().NotBeNull(); + result!.FirstId.Should().BeEmpty(); + result.LastId.Should().BeEmpty(); + result.HasMore.Should().BeFalse(); + result.Data.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs new file mode 100644 index 0000000..b63599b --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs @@ -0,0 +1,68 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class PagingRequestTests : SerializationTest +{ + [Fact] + public void Constructor_WhenLimitIsLessThanMinimum_ItShouldThrowArgumentOutOfRangeException() + { + var act = () => new PagingRequest(limit: 0); + + act.Should().Throw().WithMessage("limit must be between 1 and 1000. (Parameter 'limit')"); + } + + [Fact] + public void Constructor_WhenLimitIsGreaterThanMaximum_ItShouldThrowArgumentOutOfRangeException() + { + var act = () => new PagingRequest(limit: 1001); + + act.Should().Throw().WithMessage("limit must be between 1 and 1000. (Parameter 'limit')"); + } + + [Fact] + public void Constructor_WhenBothBeforeIdAndAfterIdAreSet_ItShouldThrowArgumentException() + { + var act = () => new PagingRequest(beforeId: "before-id", afterId: "after-id"); + + act.Should().Throw().WithMessage("Only one of beforeId or afterId can be set."); + } + + [Fact] + public void ToQueryParameters_WhenNoPropertiesSet_ItShouldReturnEmptyString() + { + var pagingRequest = new PagingRequest(); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("limit=20"); + } + + [Fact] + public void ToQueryParameters_WhenBeforeIdIsSet_ItShouldReturnBeforeId() + { + var pagingRequest = new PagingRequest(beforeId: "before-id"); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("before_id=before-id&limit=20"); + } + + [Fact] + public void ToQueryParameters_WhenAfterIdIsSet_ItShouldReturnAfterId() + { + var pagingRequest = new PagingRequest(afterId: "after-id"); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("after_id=after-id&limit=20"); + } + + [Fact] + public void ToQueryParameters_WhenLimitIsSetToNonDefaultValue_ItShouldReturnLimit() + { + var pagingRequest = new PagingRequest(limit: 10); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("limit=10"); + } +} \ No newline at end of file