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