diff --git a/.vscode/settings.json b/.vscode/settings.json
index 211b47a..b2f391d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,5 @@
{
+ "editor.formatOnSave": true,
"dotnet.defaultSolution": "AnthropicClient.sln",
"cSpell.words": [
"Browsable",
diff --git a/README.md b/README.md
index a05c5fe..dc0901f 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,73 @@ if (response.IsFailure)
Console.WriteLine("Token Count: {0}", response.Value.InputTokens);
```
+### List Models
+
+The `AnthropicApiClient` exposes a method named `ListModelsAsync` that can be used to list the available models. The method takes an optional `PagingRequest` instance as a parameter.
+
+```csharp
+using AnthropicClient;
+
+var response = await client.ListModelsAsync();
+
+if (response.IsFailure)
+{
+ Console.WriteLine("Failed to list models");
+ Console.WriteLine("Error Type: {0}", response.Error.Error.Type);
+ Console.WriteLine("Error Message: {0}", response.Error.Error.Message);
+ return;
+}
+
+foreach (var model in response.Value.Data)
+{
+ Console.WriteLine("Model Id: {0}", model.Id);
+ Console.WriteLine("Model Name: {0}", model.DisplayName);
+}
+```
+
+Using the `PagingRequest` instance allows you to specify the number of models to return and the page of models to return.
+
+```csharp
+using AnthropicClient;
+using AnthropicClient.Models;
+
+var response = await client.ListModelsAsync(new PagingRequest(afterId: "claude-3-5-sonnet-20241022", limit: 2));
+
+if (response.IsFailure)
+{
+ Console.WriteLine("Failed to list models");
+ Console.WriteLine("Error Type: {0}", response.Error.Error.Type);
+ Console.WriteLine("Error Message: {0}", response.Error.Error.Message);
+ return;
+}
+
+foreach (var model in response.Value.Data)
+{
+ Console.WriteLine("Model Id: {0}", model.Id);
+ Console.WriteLine("Model Name: {0}", model.DisplayName);
+}
+```
+
+### Get Model
+
+The `AnthropicApiClient` exposes a method named `GetModelAsync` that can be used to get a model by its id.
+
+```csharp
+using AnthropicClient;
+
+var response = await client.GetModelAsync("claude-3-5-sonnet-20241022");
+
+if (response.IsFailure)
+{
+ Console.WriteLine("Failed to get model");
+ Console.WriteLine("Error Type: {0}", response.Error.Error.Type);
+ Console.WriteLine("Error Message: {0}", response.Error.Error.Message);
+ return;
+}
+
+Console.WriteLine("Model Id: {0}", response.Value.Id);
+```
+
### Create a message
The `AnthropicApiClient` exposes a method named `CreateMessageAsync` that can be used to create a message. The method requires a `MessageRequest` or a `StreamMessageRequest` instance as a parameter. The `MessageRequest` class is used to create a message whose response is not streamed and the `StreamMessageRequest` class is used to create a message whose response is streamed. The `MessageRequest` instance's properties can be set to configure how the message is created.
diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs
index 48ea518..3be31ee 100644
--- a/src/AnthropicClient/AnthropicApiClient.cs
+++ b/src/AnthropicClient/AnthropicApiClient.cs
@@ -1,6 +1,7 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
+using System.Threading.Tasks;
using AnthropicClient.Json;
using AnthropicClient.Models;
@@ -33,6 +34,28 @@ public interface IAnthropicApiClient
/// The count message tokens request.
/// A task that represents the asynchronous operation. The task result contains the response as an where T is .
Task> CountMessageTokensAsync(CountMessageTokensRequest request);
+
+ ///
+ /// Lists the models asynchronously.
+ ///
+ /// The paging request to use for listing the models.
+ /// A task that represents the asynchronous operation. The task result contains the response as an where T is where T is .
+ Task>> ListModelsAsync(PagingRequest? request = null);
+
+ ///
+ /// Lists the models asynchronously
+ ///
+ /// The maximum number of models to return in each page.
+ /// An asynchronous enumerable that yields the response as an where T is where T is .
+ ///
+ IAsyncEnumerable>> ListAllModelsAsync(int limit = 20);
+
+ ///
+ /// Gets a model by its ID asynchronously.
+ ///
+ /// The ID of the model to get.
+ /// A task that represents the asynchronous operation. The task result contains the response as an where T is .
+ Task> GetModelAsync(string modelId);
}
///
@@ -42,6 +65,7 @@ public class AnthropicApiClient : IAnthropicApiClient
private const string ApiKeyHeader = "x-api-key";
private const string MessagesEndpoint = "messages";
private const string CountTokensEndpoint = "messages/count_tokens";
+ private const string ModelsEndpoint = "models";
private const string JsonContentType = "application/json";
private const string EventPrefix = "event:";
private const string DataPrefix = "data:";
@@ -76,7 +100,7 @@ public class AnthropicApiClient : IAnthropicApiClient
}
}
- ///
+ ///
public async Task> CreateMessageAsync(MessageRequest request)
{
var response = await SendRequestAsync(MessagesEndpoint, request);
@@ -99,7 +123,7 @@ public class AnthropicApiClient : IAnthropicApiClient
return AnthropicResult.Success(msgResponse, anthropicHeaders);
}
- ///
+ ///
public async IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request)
{
var response = await SendRequestAsync(MessagesEndpoint, request);
@@ -263,7 +287,7 @@ public class AnthropicApiClient : IAnthropicApiClient
} while (true);
}
- ///
+ ///
public async Task> CountMessageTokensAsync(CountMessageTokensRequest request)
{
var response = await SendRequestAsync(CountTokensEndpoint, request);
@@ -277,10 +301,82 @@ public class AnthropicApiClient : IAnthropicApiClient
}
var msgResponse = Deserialize(responseContent) ?? new TokenCountResponse();
-
return AnthropicResult.Success(msgResponse, anthropicHeaders);
}
+ ///
+ public async Task>> ListModelsAsync(PagingRequest? request = null)
+ {
+ var pagingRequest = request ?? new PagingRequest();
+ var endpoint = $"{ModelsEndpoint}?{pagingRequest.ToQueryParameters()}";
+ var response = await SendRequestAsync(endpoint);
+ var anthropicHeaders = new AnthropicHeaders(response.Headers);
+ var responseContent = await response.Content.ReadAsStringAsync();
+
+ if (response.IsSuccessStatusCode is false)
+ {
+ var error = Deserialize(responseContent) ?? new AnthropicError();
+ return AnthropicResult>.Failure(error, anthropicHeaders);
+ }
+
+ var page = Deserialize>(responseContent) ?? new Page();
+ return AnthropicResult>.Success(page, anthropicHeaders);
+ }
+
+ ///
+ public async IAsyncEnumerable>> ListAllModelsAsync(int limit = 20)
+ {
+ var pagingRequest = new PagingRequest(limit: limit);
+ string Endpoint() => $"{ModelsEndpoint}?{pagingRequest.ToQueryParameters()}";
+ bool hasMore;
+
+ do
+ {
+ var response = await SendRequestAsync(Endpoint());
+ var anthropicHeaders = new AnthropicHeaders(response.Headers);
+ var responseContent = await response.Content.ReadAsStringAsync();
+
+ if (response.IsSuccessStatusCode is false)
+ {
+ var error = Deserialize(responseContent) ?? new AnthropicError();
+ yield return AnthropicResult>.Failure(error, anthropicHeaders);
+ yield break;
+ }
+
+ var page = Deserialize>(responseContent) ?? new Page();
+
+ if (page.HasMore && page.LastId is not null)
+ {
+ hasMore = true;
+ pagingRequest = new PagingRequest(limit: limit, afterId: page.LastId);
+ }
+ else
+ {
+ hasMore = false;
+ }
+
+ yield return AnthropicResult>.Success(page, anthropicHeaders);
+ } while (hasMore);
+ }
+
+ ///
+ public async Task> GetModelAsync(string modelId)
+ {
+ var endpoint = $"{ModelsEndpoint}/{modelId}";
+ var response = await SendRequestAsync(endpoint);
+ var anthropicHeaders = new AnthropicHeaders(response.Headers);
+ var responseContent = await response.Content.ReadAsStringAsync();
+
+ if (response.IsSuccessStatusCode is false)
+ {
+ var error = Deserialize(responseContent) ?? new AnthropicError();
+ return AnthropicResult.Failure(error, anthropicHeaders);
+ }
+
+ var model = Deserialize(responseContent) ?? new AnthropicModel();
+ return AnthropicResult.Success(model, anthropicHeaders);
+ }
+
private ToolCall? GetToolCall(MessageResponse response, List tools)
{
var toolUse = response.Content.OfType().FirstOrDefault();
@@ -300,6 +396,11 @@ public class AnthropicApiClient : IAnthropicApiClient
return new ToolCall(tool, toolUse);
}
+ private async Task SendRequestAsync(string endpoint)
+ {
+ return await _httpClient.GetAsync(endpoint);
+ }
+
private async Task SendRequestAsync(string endpoint, T request)
{
var requestJson = Serialize(request);
diff --git a/src/AnthropicClient/Models/AnthropicModel.cs b/src/AnthropicClient/Models/AnthropicModel.cs
new file mode 100644
index 0000000..7a398f2
--- /dev/null
+++ b/src/AnthropicClient/Models/AnthropicModel.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace AnthropicClient.Models;
+
+///
+/// Represents an Anthropic model.
+///
+public class AnthropicModel
+{
+ ///
+ /// The type of the model.
+ ///
+ public string Type { get; init; } = string.Empty;
+
+ ///
+ /// The id of the model.
+ ///
+ public string Id { get; init; } = string.Empty;
+
+ ///
+ /// The display name of the model.
+ ///
+ [JsonPropertyName("display_name")]
+ public string DisplayName { get; init; } = string.Empty;
+
+ ///
+ /// The created date of the model.
+ ///
+ [JsonPropertyName("created_at")]
+ public DateTimeOffset CreatedAt { get; init; }
+}
\ No newline at end of file
diff --git a/src/AnthropicClient/Models/Page.cs b/src/AnthropicClient/Models/Page.cs
new file mode 100644
index 0000000..729d90b
--- /dev/null
+++ b/src/AnthropicClient/Models/Page.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+
+namespace AnthropicClient.Models;
+
+///
+/// Represents a page.
+///
+public class Page
+{
+ ///
+ /// The id of the first item in the page.
+ ///
+ [JsonPropertyName("first_id")]
+ public string? FirstId { get; init; } = string.Empty;
+
+ ///
+ /// The id of the last item in the page.
+ ///
+ [JsonPropertyName("last_id")]
+ public string? LastId { get; init; } = string.Empty;
+
+ ///
+ /// Indicates whether there is more data to be retrieved.
+ ///
+ [JsonPropertyName("has_more")]
+ public bool HasMore { get; init; }
+}
+
+///
+/// Represents a page with data.
+///
+public class Page : Page
+{
+ ///
+ /// The data in the page.
+ ///
+ public T[] Data { get; init; } = [];
+}
\ No newline at end of file
diff --git a/src/AnthropicClient/Models/PagingRequest.cs b/src/AnthropicClient/Models/PagingRequest.cs
new file mode 100644
index 0000000..52746df
--- /dev/null
+++ b/src/AnthropicClient/Models/PagingRequest.cs
@@ -0,0 +1,83 @@
+using System.Text.Json.Serialization;
+
+namespace AnthropicClient.Models;
+
+///
+/// Represents a request to page through a collection of items.
+///
+public class PagingRequest
+{
+ private const int LimitMinimum = 1;
+ private const int LimitMaximum = 1000;
+ private const int DefaultLimit = 20;
+
+ ///
+ /// The ID of the item before which to start the page.
+ ///
+ [JsonPropertyName("before_id")]
+ public string BeforeId { get; init; }
+
+ ///
+ /// The ID of the item after which to start the page.
+ ///
+ [JsonPropertyName("after_id")]
+ public string AfterId { get; init; }
+
+ ///
+ /// The maximum number of items to return in the page.
+ ///
+ public int Limit { get; init; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the item before which to start the page.
+ /// The ID of the item after which to start the page.
+ /// The maximum number of items to return in the page.
+ /// Thrown when both and are specified.
+ /// Thrown when is less than 1 or greater than 1000.
+ /// A new instance of the class.
+ public PagingRequest(
+ string beforeId = "",
+ string afterId = "",
+ int limit = DefaultLimit
+ )
+ {
+ if (limit is < LimitMinimum or > LimitMaximum)
+ {
+ throw new ArgumentOutOfRangeException(nameof(limit), $"{nameof(limit)} must be between {LimitMinimum} and {LimitMaximum}.");
+ }
+
+ if (string.IsNullOrEmpty(beforeId) is false && string.IsNullOrEmpty(afterId) is false)
+ {
+ throw new ArgumentException($"Only one of {nameof(beforeId)} or {nameof(afterId)} can be set.");
+ }
+
+ BeforeId = beforeId;
+ AfterId = afterId;
+ Limit = limit;
+ }
+
+ ///
+ /// Converts the to a query string.
+ ///
+ /// The query string representation of the .
+ public string ToQueryParameters()
+ {
+ var parameters = new List();
+
+ if (string.IsNullOrEmpty(BeforeId) is false)
+ {
+ parameters.Add($"before_id={BeforeId}");
+ }
+
+ if (string.IsNullOrEmpty(AfterId) is false)
+ {
+ parameters.Add($"after_id={AfterId}");
+ }
+
+ parameters.Add($"limit={Limit}");
+
+ return string.Join("&", parameters);
+ }
+}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs
index 50dab64..47ccadd 100644
--- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs
+++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs
@@ -303,4 +303,42 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf
result.Value.Should().BeOfType();
result.Value.InputTokens.Should().BeGreaterThan(0);
}
+
+ [Fact]
+ public async Task ListModelsAsync_WhenCalled_ItShouldReturnResponse()
+ {
+ var result = await _client.ListModelsAsync();
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType>();
+ result.Value.Data.Should().HaveCountGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task ListModelsAsync_WhenCalledWithPagination_ItShouldReturnResponse()
+ {
+ var result = await _client.ListModelsAsync(new PagingRequest(limit: 1));
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType>();
+ result.Value.Data.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task ListAllModelsAsync_WhenCalled_ItShouldReturnResponse()
+ {
+ var responses = await _client.ListAllModelsAsync(limit: 1).ToListAsync();
+
+ responses.Should().HaveCountGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task GetModelAsync_WhenCalled_ItShouldReturnResponse()
+ {
+ var result = await _client.GetModelAsync(AnthropicModels.Claude3Haiku);
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType();
+ result.Value.Id.Should().Be(AnthropicModels.Claude3Haiku);
+ }
}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs
index f874a31..eaef353 100644
--- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs
+++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs
@@ -57,7 +57,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10,
""output_tokens"": 25
}
- }"
+ }"
);
var request = new MessageRequest(
@@ -115,7 +115,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10,
""output_tokens"": 25
}
- }"
+ }"
);
var func = (string ticker) => ticker;
@@ -191,7 +191,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10,
""output_tokens"": 25
}
- }"
+ }"
);
var request = new MessageRequest(
@@ -331,12 +331,12 @@ public class AnthropicApiClientTests : IntegrationTest
HttpStatusCode.BadRequest,
"application/json",
@"{
- ""type"": ""error"",
- ""error"": {
- ""type"": ""invalid_request_error"",
- ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
- }
- }"
+ ""type"": ""error"",
+ ""error"": {
+ ""type"": ""invalid_request_error"",
+ ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
+ }
+ }"
);
var request = new StreamMessageRequest(
@@ -385,7 +385,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10,
""output_tokens"": 25
}
- }"
+ }"
);
var request = new MessageRequest(
@@ -428,8 +428,8 @@ public class AnthropicApiClientTests : IntegrationTest
HttpStatusCode.OK,
"application/json",
@"{
- ""input_tokens"": 10
- }"
+ ""input_tokens"": 10
+ }"
);
var request = new CountMessageTokensRequest(
@@ -455,12 +455,12 @@ public class AnthropicApiClientTests : IntegrationTest
HttpStatusCode.BadRequest,
"application/json",
@"{
- ""type"": ""error"",
- ""error"": {
- ""type"": ""invalid_request_error"",
- ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
- }
- }"
+ ""type"": ""error"",
+ ""error"": {
+ ""type"": ""invalid_request_error"",
+ ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
+ }
+ }"
);
var request = new CountMessageTokensRequest(
@@ -479,7 +479,7 @@ public class AnthropicApiClientTests : IntegrationTest
}
[Fact]
- public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError()
+ public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError()
{
_mockHttpMessageHandler
.WhenCountMessageTokensRequest()
@@ -503,4 +503,479 @@ public class AnthropicApiClientTests : IntegrationTest
result.Error.Should().BeOfType();
result.Error.Error.Should().BeOfType();
}
+
+ [Fact]
+ public async Task ListModelsAsync_WhenCalledWithPagingRequestUsingDefaultValues_ItShouldReturnListOfModels()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""data"": [
+ {
+ ""type"": ""model"",
+ ""id"": ""claude-3-5-sonnet-20241022"",
+ ""display_name"": ""Claude 3.5 Sonnet (New)"",
+ ""created_at"": ""2024-10-22T00:00:00Z""
+ }
+ ],
+ ""has_more"": true,
+ ""first_id"": ""first_id"",
+ ""last_id"": ""last_id""
+ }"
+ );
+
+ var result = await Client.ListModelsAsync();
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType>();
+ result.Value.HasMore.Should().BeTrue();
+ result.Value.FirstId.Should().Be("first_id");
+ result.Value.LastId.Should().Be("last_id");
+ result.Value.Data.Should().BeEquivalentTo(new AnthropicModel[]
+ {
+ new()
+ {
+ Type = "model",
+ Id = "claude-3-5-sonnet-20241022",
+ DisplayName = "Claude 3.5 Sonnet (New)",
+ CreatedAt = DateTimeOffset.Parse("2024-10-22T00:00:00Z")
+ }
+ });
+ }
+
+ [Fact]
+ public async Task ListModelsAsync_WhenCalledWithPagingRequestUsingCustomValues_ItShouldReturnListOfModels()
+ {
+ var pagingRequest = new PagingRequest(afterId: "next_id", limit: 10);
+
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .WithQueryString(new Dictionary
+ {
+ { "after_id", pagingRequest.AfterId },
+ { "limit", pagingRequest.Limit.ToString() },
+ })
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""data"": [
+ {
+ ""type"": ""model"",
+ ""id"": ""claude-3-5-sonnet-20241022"",
+ ""display_name"": ""Claude 3.5 Sonnet (New)"",
+ ""created_at"": ""2024-10-22T00:00:00Z""
+ }
+ ],
+ ""has_more"": true,
+ ""first_id"": ""first_id"",
+ ""last_id"": ""last_id""
+ }"
+ );
+
+ var result = await Client.ListModelsAsync(pagingRequest);
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType>();
+ result.Value.HasMore.Should().BeTrue();
+ result.Value.FirstId.Should().Be("first_id");
+ result.Value.LastId.Should().Be("last_id");
+ result.Value.Data.Should().BeEquivalentTo(new AnthropicModel[]
+ {
+ new()
+ {
+ Type = "model",
+ Id = "claude-3-5-sonnet-20241022",
+ DisplayName = "Claude 3.5 Sonnet (New)",
+ CreatedAt = DateTimeOffset.Parse("2024-10-22T00:00:00Z")
+ }
+ });
+ }
+
+ [Fact]
+ public async Task ListModelsAsync_WhenCalledAndNoModelsReturned_ItShouldReturnEmptyList()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""data"": [],
+ ""has_more"": false,
+ ""first_id"": null,
+ ""last_id"": null
+ }"
+ );
+
+ var result = await Client.ListModelsAsync();
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType>();
+ result.Value.HasMore.Should().BeFalse();
+ result.Value.FirstId.Should().BeNull();
+ result.Value.LastId.Should().BeNull();
+ result.Value.Data.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ListModelsAsync_WhenCalledAndErrorReturned_ItShouldHandleError()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .Respond(
+ HttpStatusCode.BadRequest,
+ "application/json",
+ @"{
+ ""type"": ""error"",
+ ""error"": {
+ ""type"": ""invalid_request_error"",
+ ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
+ }
+ }"
+ );
+
+ var result = await Client.ListModelsAsync();
+
+ result.IsSuccess.Should().BeFalse();
+ result.Error.Should().BeOfType();
+ result.Error.Error.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ListModelsAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .Respond(
+ HttpStatusCode.BadRequest,
+ "application/json",
+ @"{}"
+ );
+
+ var result = await Client.ListModelsAsync();
+
+ result.IsSuccess.Should().BeFalse();
+ result.Error.Should().BeOfType();
+ result.Error.Error.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ListAllModelsAsync_WhenCalled_ItShouldReturnAllModels()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .WithExactQueryString(new Dictionary()
+ {
+ { "limit", "20" },
+ })
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""data"": [
+ {
+ ""type"": ""model"",
+ ""id"": ""claude-3-5-sonnet-20241022"",
+ ""display_name"": ""Claude 3.5 Sonnet (New)"",
+ ""created_at"": ""2024-10-22T00:00:00Z""
+ }
+ ],
+ ""has_more"": true,
+ ""first_id"": ""1"",
+ ""last_id"": ""1""
+ }"
+ );
+
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .WithExactQueryString(new Dictionary()
+ {
+ { "after_id", "1" },
+ { "limit", "20" },
+ })
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""data"": [
+ {
+ ""type"": ""model"",
+ ""id"": ""claude-3-5-sonnet-20241023"",
+ ""display_name"": ""Claude 3.5 Sonnet (New)"",
+ ""created_at"": ""2024-10-23T00:00:00Z""
+ }
+ ],
+ ""has_more"": false,
+ ""first_id"": ""2"",
+ ""last_id"": ""2""
+ }"
+ );
+
+ var pageResponses = Client.ListAllModelsAsync();
+ var collectedPages = new List>();
+
+ await foreach (var response in pageResponses)
+ {
+ response.IsSuccess.Should().BeTrue();
+ response.Value.Should().BeOfType>();
+ collectedPages.Add(response.Value);
+ }
+
+ collectedPages.Should().HaveCount(2);
+ collectedPages.Should().BeEquivalentTo(new Page[]
+ {
+ new()
+ {
+ HasMore = true,
+ FirstId = "1",
+ LastId = "1",
+ Data = [
+ new()
+ {
+ Type = "model",
+ Id = "claude-3-5-sonnet-20241022",
+ DisplayName = "Claude 3.5 Sonnet (New)",
+ CreatedAt = DateTimeOffset.Parse("2024-10-22T00:00:00Z")
+ }
+ ]
+ },
+ new()
+ {
+ HasMore = false,
+ FirstId = "2",
+ LastId = "2",
+ Data = [
+ new()
+ {
+ Type = "model",
+ Id = "claude-3-5-sonnet-20241023",
+ DisplayName = "Claude 3.5 Sonnet (New)",
+ CreatedAt = DateTimeOffset.Parse("2024-10-23T00:00:00Z")
+ }
+ ]
+ }
+ });
+ }
+
+ [Fact]
+ public async Task ListAllModelsAsync_WhenCalledAndErrorReturned_ItShouldHandleError()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .Respond(
+ HttpStatusCode.BadRequest,
+ "application/json",
+ @"{
+ ""type"": ""error"",
+ ""error"": {
+ ""type"": ""invalid_request_error"",
+ ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
+ }
+ }"
+ );
+
+ var responses = Client.ListAllModelsAsync();
+ var count = 0;
+
+ await foreach (var page in responses)
+ {
+ count++;
+ page.IsSuccess.Should().BeFalse();
+ page.Error.Should().BeOfType();
+ page.Error.Error.Should().BeOfType();
+ }
+
+ count.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task ListAllModelsAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .Respond(
+ HttpStatusCode.BadRequest,
+ "application/json",
+ @"{}"
+ );
+
+ var responses = Client.ListAllModelsAsync();
+ var count = 0;
+
+ await foreach (var page in responses)
+ {
+ count++;
+ page.IsSuccess.Should().BeFalse();
+ page.Error.Should().BeOfType();
+ page.Error.Error.Should().BeOfType();
+ }
+
+ count.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task ListAllModelsAsync_WhenFirstPageSucceedsAndSecondPageFails_ItShouldReturnFirstPageAndError()
+ {
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .WithExactQueryString(new Dictionary
+ {
+ { "limit", "20" },
+ })
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""data"": [
+ {
+ ""type"": ""model"",
+ ""id"": ""claude-3-5-sonnet-20241022"",
+ ""display_name"": ""Claude 3.5 Sonnet (New)"",
+ ""created_at"": ""2024-10-22T00:00:00Z""
+ }
+ ],
+ ""has_more"": true,
+ ""first_id"": ""1"",
+ ""last_id"": ""1""
+ }"
+ );
+
+ _mockHttpMessageHandler
+ .WhenListModelsRequest()
+ .WithExactQueryString(new Dictionary
+ {
+ { "after_id", "1" },
+ { "limit", "20" },
+ })
+ .Respond(
+ HttpStatusCode.BadRequest,
+ "application/json",
+ @"{
+ ""type"": ""error"",
+ ""error"": {
+ ""type"": ""invalid_request_error"",
+ ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
+ }
+ }"
+ );
+
+ var responses = Client.ListAllModelsAsync();
+ var count = 0;
+
+ await foreach (var page in responses)
+ {
+ count++;
+
+ if (count == 1)
+ {
+ page.IsSuccess.Should().BeTrue();
+ page.Value.Should().BeOfType>();
+ }
+ else
+ {
+ page.IsSuccess.Should().BeFalse();
+ page.Error.Should().BeOfType();
+ page.Error.Error.Should().BeOfType();
+ }
+ }
+
+ count.Should().Be(2);
+ }
+
+ [Fact]
+ public async Task GetModelAsync_WhenCalled_ItShouldReturnModel()
+ {
+ var modelId = "claude-3-5-sonnet-20241022";
+
+ _mockHttpMessageHandler
+ .WhenGetModelRequest(modelId)
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""type"": ""model"",
+ ""id"": ""claude-3-5-sonnet-20241022"",
+ ""display_name"": ""Claude 3.5 Sonnet (New)"",
+ ""created_at"": ""2024-10-22T00:00:00Z""
+ }"
+ );
+
+ var result = await Client.GetModelAsync(modelId);
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType();
+ result.Value.Type.Should().Be("model");
+ result.Value.Id.Should().Be("claude-3-5-sonnet-20241022");
+ result.Value.DisplayName.Should().Be("Claude 3.5 Sonnet (New)");
+ result.Value.CreatedAt.Should().Be(DateTimeOffset.Parse("2024-10-22T00:00:00Z"));
+ }
+
+ [Fact]
+ public async Task GetModelAsync_WhenCalledAndErrorReturned_ItShouldHandleError()
+ {
+ var modelId = "claude-3-5-sonnet-20241022";
+
+ _mockHttpMessageHandler
+ .WhenGetModelRequest(modelId)
+ .Respond(
+ HttpStatusCode.BadRequest,
+ "application/json",
+ @"{
+ ""type"": ""error"",
+ ""error"": {
+ ""type"": ""invalid_request_error"",
+ ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
+ }
+ }"
+ );
+
+ var result = await Client.GetModelAsync(modelId);
+
+ result.IsSuccess.Should().BeFalse();
+ result.Error.Should().BeOfType();
+ result.Error.Error.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task GetModelAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError()
+ {
+ var modelId = "claude-3-5-sonnet-20241022";
+
+ _mockHttpMessageHandler
+ .WhenGetModelRequest(modelId)
+ .Respond(
+ HttpStatusCode.BadRequest,
+ "application/json",
+ @"{}"
+ );
+
+ var result = await Client.GetModelAsync(modelId);
+
+ result.IsSuccess.Should().BeFalse();
+ result.Error.Should().BeOfType();
+ result.Error.Error.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task GetModelAsync_WhenCalledAndCanNotDeserializeModel_ItShouldReturnEmptyModel()
+ {
+ var modelId = "claude-3-5-sonnet-20241022";
+
+ _mockHttpMessageHandler
+ .WhenGetModelRequest(modelId)
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{}"
+ );
+
+ var result = await Client.GetModelAsync(modelId);
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType();
+ }
}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs
index fd04cec..6b6376d 100644
--- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs
+++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs
@@ -16,6 +16,7 @@ public static class MockHttpMessageHandlerExtensions
private const string BaseUrl = "https://api.anthropic.com/v1";
private static readonly string MessagesEndpoint = $"{BaseUrl}/messages";
private static readonly string CountTokensEndpoint = $"{BaseUrl}/messages/count_tokens";
+ private static readonly string ModelsEndpoint = $"{BaseUrl}/models";
private static MockedRequest SetupBaseRequest(
this MockHttpMessageHandler mockHttpMessageHandler,
@@ -51,4 +52,16 @@ public static class MockHttpMessageHandlerExtensions
return mockHttpMessageHandler
.SetupBaseRequest(HttpMethod.Post, CountTokensEndpoint);
}
+
+ public static MockedRequest WhenListModelsRequest(this MockHttpMessageHandler mockHttpMessageHandler)
+ {
+ return mockHttpMessageHandler
+ .SetupBaseRequest(HttpMethod.Get, ModelsEndpoint);
+ }
+
+ public static MockedRequest WhenGetModelRequest(this MockHttpMessageHandler mockHttpMessageHandler, string modelId)
+ {
+ return mockHttpMessageHandler
+ .SetupBaseRequest(HttpMethod.Get, $"{ModelsEndpoint}/{modelId}");
+ }
}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelTests.cs
new file mode 100644
index 0000000..b7d1662
--- /dev/null
+++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelTests.cs
@@ -0,0 +1,57 @@
+namespace AnthropicClient.Tests.Unit.Models;
+
+public class AnthropicModelTests : SerializationTest
+{
+ private const string SampleJson = @"{
+ ""type"": ""model"",
+ ""id"": ""claude-3-opus-20240229"",
+ ""display_name"": ""Claude 3 Opus"",
+ ""created_at"": ""2024-02-29T00:00:00Z""
+ }";
+
+ [Fact]
+ public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet()
+ {
+ var model = new AnthropicModel();
+
+ model.Type.Should().BeEmpty();
+ model.Id.Should().BeEmpty();
+ model.DisplayName.Should().BeEmpty();
+ model.CreatedAt.Should().Be(default);
+ }
+
+ [Fact]
+ public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedValues()
+ {
+ var result = Deserialize(SampleJson);
+
+ result.Should().NotBeNull();
+ result!.Type.Should().Be("model");
+ result.Id.Should().Be("claude-3-opus-20240229");
+ result.DisplayName.Should().Be("Claude 3 Opus");
+ result.CreatedAt.Should().Be(new DateTimeOffset(2024, 2, 29, 0, 0, 0, TimeSpan.Zero));
+ }
+
+ [Fact]
+ public void JsonSerialization_WhenSerialized_ItShouldReturnExpectedShape()
+ {
+ var model = new AnthropicModel
+ {
+ Type = "model",
+ Id = "claude-3-opus-20240229",
+ DisplayName = "Claude 3 Opus",
+ CreatedAt = new DateTimeOffset(2024, 2, 29, 0, 0, 0, TimeSpan.Zero)
+ };
+
+ var result = Serialize(model);
+
+ var expectedJson = @"{
+ ""type"": ""model"",
+ ""id"": ""claude-3-opus-20240229"",
+ ""display_name"": ""Claude 3 Opus"",
+ ""created_at"": ""2024-02-29T00:00:00+00:00""
+ }";
+
+ JsonAssert.Equal(expectedJson, result);
+ }
+}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Unit/Models/PageTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PageTests.cs
new file mode 100644
index 0000000..61a2780
--- /dev/null
+++ b/tests/AnthropicClient.Tests/Unit/Models/PageTests.cs
@@ -0,0 +1,111 @@
+namespace AnthropicClient.Tests.Unit.Models;
+
+public class PageTests : SerializationTest
+{
+ private const string BasePageJson = @"{
+ ""first_id"": ""msg_123"",
+ ""last_id"": ""msg_456"",
+ ""has_more"": true
+ }";
+
+ private const string GenericPageJson = @"{
+ ""first_id"": ""msg_123"",
+ ""last_id"": ""msg_456"",
+ ""has_more"": true,
+ ""data"": [""item1"", ""item2""]
+ }";
+
+ private const string EmptyPageJson = @"{
+ ""first_id"": """",
+ ""last_id"": """",
+ ""has_more"": false,
+ ""data"": []
+ }";
+
+ [Fact]
+ public void Constructor_WhenCalled_ItShouldReturnAnInstanceWithPropertiesSet()
+ {
+ var page = new Page();
+
+ page.FirstId.Should().BeEmpty();
+ page.LastId.Should().BeEmpty();
+ page.HasMore.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Constructor_WhenCalledWithGeneric_ItShouldReturnAnInstanceWithPropertiesSet()
+ {
+ var page = new Page();
+
+ page.FirstId.Should().BeEmpty();
+ page.LastId.Should().BeEmpty();
+ page.HasMore.Should().BeFalse();
+ page.Data.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void JsonDeserialization_WhenCalled_ItShouldHaveCorrectValues()
+ {
+ var result = Deserialize(BasePageJson);
+
+ result.Should().NotBeNull();
+ result!.FirstId.Should().Be("msg_123");
+ result.LastId.Should().Be("msg_456");
+ result.HasMore.Should().BeTrue();
+ }
+
+ [Fact]
+ public void JsonSerialization_WhenCalled_ItShouldHaveExpectedShape()
+ {
+ var page = new Page
+ {
+ FirstId = "msg_123",
+ LastId = "msg_456",
+ HasMore = true
+ };
+
+ var result = Serialize(page);
+
+ JsonAssert.Equal(BasePageJson, result);
+ }
+
+ [Fact]
+ public void JsonDeserialization_WhenCalledWithData_ItShouldHaveCorrectValues()
+ {
+ var result = Deserialize>(GenericPageJson);
+
+ result.Should().NotBeNull();
+ result!.FirstId.Should().Be("msg_123");
+ result.LastId.Should().Be("msg_456");
+ result.HasMore.Should().BeTrue();
+ result.Data.Should().BeEquivalentTo(["item1", "item2"]);
+ }
+
+ [Fact]
+ public void JsonSerialization_WhenCalledWithData_ItShouldHaveExpectedShape()
+ {
+ var page = new Page
+ {
+ FirstId = "msg_123",
+ LastId = "msg_456",
+ HasMore = true,
+ Data = ["item1", "item2"]
+ };
+
+ var result = Serialize(page);
+
+ JsonAssert.Equal(GenericPageJson, result);
+ }
+
+ [Fact]
+ public void JsonDeserialization_WhenCalledWithEmptyData_ItShouldHaveCorrectValues()
+ {
+ var result = Deserialize>(EmptyPageJson);
+
+ result.Should().NotBeNull();
+ result!.FirstId.Should().BeEmpty();
+ result.LastId.Should().BeEmpty();
+ result.HasMore.Should().BeFalse();
+ result.Data.Should().BeEmpty();
+ }
+}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs
new file mode 100644
index 0000000..b63599b
--- /dev/null
+++ b/tests/AnthropicClient.Tests/Unit/Models/PagingRequestTests.cs
@@ -0,0 +1,68 @@
+namespace AnthropicClient.Tests.Unit.Models;
+
+public class PagingRequestTests : SerializationTest
+{
+ [Fact]
+ public void Constructor_WhenLimitIsLessThanMinimum_ItShouldThrowArgumentOutOfRangeException()
+ {
+ var act = () => new PagingRequest(limit: 0);
+
+ act.Should().Throw().WithMessage("limit must be between 1 and 1000. (Parameter 'limit')");
+ }
+
+ [Fact]
+ public void Constructor_WhenLimitIsGreaterThanMaximum_ItShouldThrowArgumentOutOfRangeException()
+ {
+ var act = () => new PagingRequest(limit: 1001);
+
+ act.Should().Throw().WithMessage("limit must be between 1 and 1000. (Parameter 'limit')");
+ }
+
+ [Fact]
+ public void Constructor_WhenBothBeforeIdAndAfterIdAreSet_ItShouldThrowArgumentException()
+ {
+ var act = () => new PagingRequest(beforeId: "before-id", afterId: "after-id");
+
+ act.Should().Throw().WithMessage("Only one of beforeId or afterId can be set.");
+ }
+
+ [Fact]
+ public void ToQueryParameters_WhenNoPropertiesSet_ItShouldReturnEmptyString()
+ {
+ var pagingRequest = new PagingRequest();
+
+ var result = pagingRequest.ToQueryParameters();
+
+ result.Should().Be("limit=20");
+ }
+
+ [Fact]
+ public void ToQueryParameters_WhenBeforeIdIsSet_ItShouldReturnBeforeId()
+ {
+ var pagingRequest = new PagingRequest(beforeId: "before-id");
+
+ var result = pagingRequest.ToQueryParameters();
+
+ result.Should().Be("before_id=before-id&limit=20");
+ }
+
+ [Fact]
+ public void ToQueryParameters_WhenAfterIdIsSet_ItShouldReturnAfterId()
+ {
+ var pagingRequest = new PagingRequest(afterId: "after-id");
+
+ var result = pagingRequest.ToQueryParameters();
+
+ result.Should().Be("after_id=after-id&limit=20");
+ }
+
+ [Fact]
+ public void ToQueryParameters_WhenLimitIsSetToNonDefaultValue_ItShouldReturnLimit()
+ {
+ var pagingRequest = new PagingRequest(limit: 10);
+
+ var result = pagingRequest.ToQueryParameters();
+
+ result.Should().Be("limit=10");
+ }
+}
\ No newline at end of file