From 9e367d35426c0ce036f006d8087996fb5ea60e2f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:45:04 -0600 Subject: [PATCH 01/10] chore: update settings to format on save --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) 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", From 28b7bf6a897cf3b2e2aad7df767b0f4428bec7a4 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:45:37 -0600 Subject: [PATCH 02/10] feat: add classes to model response to get model list endpoint --- src/AnthropicClient/Models/AnthropicModel.cs | 31 +++++ src/AnthropicClient/Models/Page.cs | 38 ++++++ .../Unit/Models/AnthropicModelTests.cs | 57 +++++++++ .../Unit/Models/PageTests.cs | 111 ++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 src/AnthropicClient/Models/AnthropicModel.cs create mode 100644 src/AnthropicClient/Models/Page.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AnthropicModelTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/PageTests.cs diff --git a/src/AnthropicClient/Models/AnthropicModel.cs b/src/AnthropicClient/Models/AnthropicModel.cs new file mode 100644 index 0000000..50be8c1 --- /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; } +} diff --git a/src/AnthropicClient/Models/Page.cs b/src/AnthropicClient/Models/Page.cs new file mode 100644 index 0000000..e0ec5a8 --- /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/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 From df75cd25e0a1beb55be97d5fcb8a718806793f51 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 16:57:13 -0600 Subject: [PATCH 03/10] feat: add model to represent paged request --- src/AnthropicClient/Models/PagingRequest.cs | 80 +++++++++++++++++++ .../Unit/Models/PagingRequestTests.cs | 70 ++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/AnthropicClient/Models/PagingRequest.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs diff --git a/src/AnthropicClient/Models/PagingRequest.cs b/src/AnthropicClient/Models/PagingRequest.cs new file mode 100644 index 0000000..b7aad83 --- /dev/null +++ b/src/AnthropicClient/Models/PagingRequest.cs @@ -0,0 +1,80 @@ +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 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}."); + } + + 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}"); + } + + if (Limit is not DefaultLimit) + { + parameters.Add($"limit={Limit}"); + } + + return string.Join("&", parameters); + } +} \ 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..54e6c8f --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs @@ -0,0 +1,70 @@ +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 ToQueryParameters_WhenNoPropertiesSet_ItShouldReturnEmptyString() + { + var pagingRequest = new PagingRequest(); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().BeEmpty(); + } + + [Fact] + public void ToQueryParameters_WhenBeforeIdIsSet_ItShouldReturnBeforeId() + { + var pagingRequest = new PagingRequest(beforeId: "before-id"); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("before_id=before-id"); + } + + [Fact] + public void ToQueryParameters_WhenAfterIdIsSet_ItShouldReturnAfterId() + { + var pagingRequest = new PagingRequest(afterId: "after-id"); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("after_id=after-id"); + } + + [Fact] + public void ToQueryParameters_WhenLimitIsSetToNonDefaultValue_ItShouldReturnLimit() + { + var pagingRequest = new PagingRequest(limit: 10); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("limit=10"); + } + + [Fact] + public void ToQueryParameters_WhenAllPropertiesAreSet_ItShouldReturnAllProperties() + { + var pagingRequest = new PagingRequest(beforeId: "before-id", afterId: "after-id", limit: 10); + + var result = pagingRequest.ToQueryParameters(); + + result.Should().Be("before_id=before-id&after_id=after-id&limit=10"); + } +} \ No newline at end of file From 9d341011dfdefcf1b8094238de52369554240e03 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 16:57:30 -0600 Subject: [PATCH 04/10] chore: stub out methods for retrieving list of models --- src/AnthropicClient/AnthropicApiClient.cs | 33 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 48ea518..9a92093 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -33,6 +33,21 @@ 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); + + /// + /// 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); } /// @@ -76,7 +91,7 @@ public class AnthropicApiClient : IAnthropicApiClient } } - /// + /// public async Task> CreateMessageAsync(MessageRequest request) { var response = await SendRequestAsync(MessagesEndpoint, request); @@ -99,7 +114,7 @@ public class AnthropicApiClient : IAnthropicApiClient return AnthropicResult.Success(msgResponse, anthropicHeaders); } - /// + /// public async IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request) { var response = await SendRequestAsync(MessagesEndpoint, request); @@ -263,7 +278,7 @@ public class AnthropicApiClient : IAnthropicApiClient } while (true); } - /// + /// public async Task> CountMessageTokensAsync(CountMessageTokensRequest request) { var response = await SendRequestAsync(CountTokensEndpoint, request); @@ -309,4 +324,16 @@ 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 From 8e0443a6bb4736207d5ddf173f8ec966d03cec46 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:20:27 -0600 Subject: [PATCH 05/10] feat: implement ListAllModelsAsync and ListModelsAsync --- src/AnthropicClient/AnthropicApiClient.cs | 81 +++- src/AnthropicClient/Models/Page.cs | 4 +- src/AnthropicClient/Models/PagingRequest.cs | 5 +- .../EndToEnd/AnthropicApiClientTests.cs | 28 ++ .../Integration/AnthropicApiClientTests.cs | 419 +++++++++++++++++- .../Integration/IntegrationTest.cs | 7 + .../Unit/Models/PagingRequestTests.cs | 6 +- 7 files changed, 507 insertions(+), 43 deletions(-) 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] From 8bbd4bb7c8c3f4e6fdd6d85d68d728794b85831f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:36:34 -0600 Subject: [PATCH 06/10] feat: implement GetModelAsync method --- src/AnthropicClient/AnthropicApiClient.cs | 25 +++++ .../EndToEnd/AnthropicApiClientTests.cs | 10 ++ .../Integration/AnthropicApiClientTests.cs | 99 ++++++++++++++++++- .../Integration/IntegrationTest.cs | 6 ++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 836a6bd..3be31ee 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -49,6 +49,13 @@ public interface IAnthropicApiClient /// 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); } /// @@ -352,6 +359,24 @@ public class AnthropicApiClient : IAnthropicApiClient } 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(); diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index e64893a..47ccadd 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -331,4 +331,14 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf 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 795b132..2dcb1be 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -479,7 +479,7 @@ public class AnthropicApiClientTests : IntegrationTest } [Fact] - public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() + public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() { _mockHttpMessageHandler .WhenCountMessageTokensRequest() @@ -647,7 +647,7 @@ public class AnthropicApiClientTests : IntegrationTest } [Fact] - public async Task ListModelsAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() + public async Task ListModelsAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() { _mockHttpMessageHandler .WhenListModelsRequest() @@ -794,7 +794,7 @@ public class AnthropicApiClientTests : IntegrationTest } [Fact] - public async Task ListAllModelsAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() + public async Task ListAllModelsAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError() { _mockHttpMessageHandler .WhenListModelsRequest() @@ -886,4 +886,97 @@ public class AnthropicApiClientTests : IntegrationTest 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().BeFalse(); + result.Error.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 1d5b77a..6b6376d 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -58,4 +58,10 @@ public static class MockHttpMessageHandlerExtensions 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 From 9abad390f1fb630219479278d7b57943ef6ba3d9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:37:50 -0600 Subject: [PATCH 07/10] tests: fix assertions --- .../Integration/AnthropicApiClientTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 2dcb1be..8c8f0b3 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -976,7 +976,7 @@ public class AnthropicApiClientTests : IntegrationTest var result = await Client.GetModelAsync(modelId); - result.IsSuccess.Should().BeFalse(); - result.Error.Should().BeOfType(); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); } } \ No newline at end of file From 3fe75f511d6c6adae7971b6402353c7408b6d513 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:55:53 -0600 Subject: [PATCH 08/10] fix: only allow afterId or beforeId to be set. not both at same time. --- src/AnthropicClient/Models/PagingRequest.cs | 6 ++++++ .../Integration/AnthropicApiClientTests.cs | 3 +-- .../Unit/Models/PagingRequestTests.cs | 18 ++++++++---------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/AnthropicClient/Models/PagingRequest.cs b/src/AnthropicClient/Models/PagingRequest.cs index f8b415a..52746df 100644 --- a/src/AnthropicClient/Models/PagingRequest.cs +++ b/src/AnthropicClient/Models/PagingRequest.cs @@ -34,6 +34,7 @@ public class PagingRequest /// 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( @@ -47,6 +48,11 @@ public class PagingRequest 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; diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 8c8f0b3..eaef353 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -549,13 +549,12 @@ public class AnthropicApiClientTests : IntegrationTest [Fact] public async Task ListModelsAsync_WhenCalledWithPagingRequestUsingCustomValues_ItShouldReturnListOfModels() { - var pagingRequest = new PagingRequest("prev_id", "next_id", 10); + var pagingRequest = new PagingRequest(afterId: "next_id", limit: 10); _mockHttpMessageHandler .WhenListModelsRequest() .WithQueryString(new Dictionary { - { "before_id", pagingRequest.BeforeId }, { "after_id", pagingRequest.AfterId }, { "limit", pagingRequest.Limit.ToString() }, }) diff --git a/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs index 069120c..b63599b 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs @@ -18,6 +18,14 @@ public class PagingRequestTests : SerializationTest 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() { @@ -57,14 +65,4 @@ public class PagingRequestTests : SerializationTest result.Should().Be("limit=10"); } - - [Fact] - public void ToQueryParameters_WhenAllPropertiesAreSet_ItShouldReturnAllProperties() - { - var pagingRequest = new PagingRequest(beforeId: "before-id", afterId: "after-id", limit: 10); - - var result = pagingRequest.ToQueryParameters(); - - result.Should().Be("before_id=before-id&after_id=after-id&limit=10"); - } } \ No newline at end of file From 33191a32cc78fb44944d2ef56f5158de027d9a70 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:56:14 -0600 Subject: [PATCH 09/10] docs: add examples for the model api to README.md --- README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) 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. From 0b81bfc2f6e7240fc4251de17cb5d83fc8397b3b Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 5 Jan 2025 21:57:18 -0600 Subject: [PATCH 10/10] chore: run dotnet format --- src/AnthropicClient/Models/AnthropicModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AnthropicClient/Models/AnthropicModel.cs b/src/AnthropicClient/Models/AnthropicModel.cs index 50be8c1..7a398f2 100644 --- a/src/AnthropicClient/Models/AnthropicModel.cs +++ b/src/AnthropicClient/Models/AnthropicModel.cs @@ -28,4 +28,4 @@ public class AnthropicModel /// [JsonPropertyName("created_at")] public DateTimeOffset CreatedAt { get; init; } -} +} \ No newline at end of file