Merge pull request #30 from StevanFreeborn/stevanfreeborn/feat/add-support-for-models-API

feat: add support for models API
This commit is contained in:
Stevan Freeborn
2025-01-05 22:03:17 -06:00
committed by GitHub
12 changed files with 1106 additions and 23 deletions
+1
View File
@@ -1,4 +1,5 @@
{ {
"editor.formatOnSave": true,
"dotnet.defaultSolution": "AnthropicClient.sln", "dotnet.defaultSolution": "AnthropicClient.sln",
"cSpell.words": [ "cSpell.words": [
"Browsable", "Browsable",
+67
View File
@@ -134,6 +134,73 @@ if (response.IsFailure)
Console.WriteLine("Token Count: {0}", response.Value.InputTokens); 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 ### 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. 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.
+105 -4
View File
@@ -1,6 +1,7 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks;
using AnthropicClient.Json; using AnthropicClient.Json;
using AnthropicClient.Models; using AnthropicClient.Models;
@@ -33,6 +34,28 @@ public interface IAnthropicApiClient
/// <param name="request">The count message tokens request.</param> /// <param name="request">The count message tokens request.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the response as an <see cref="AnthropicResult{T}"/> where T is <see cref="TokenCountResponse"/>.</returns> /// <returns>A task that represents the asynchronous operation. The task result contains the response as an <see cref="AnthropicResult{T}"/> where T is <see cref="TokenCountResponse"/>.</returns>
Task<AnthropicResult<TokenCountResponse>> CountMessageTokensAsync(CountMessageTokensRequest request); Task<AnthropicResult<TokenCountResponse>> CountMessageTokensAsync(CountMessageTokensRequest request);
/// <summary>
/// Lists the models asynchronously.
/// </summary>
/// <param name="request">The paging request to use for listing the models.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the response as an <see cref="AnthropicResult{T}"/> where T is <see cref="Page{T}"/> where T is <see cref="AnthropicModel"/>.</returns>
Task<AnthropicResult<Page<AnthropicModel>>> ListModelsAsync(PagingRequest? request = null);
/// <summary>
/// Lists the models asynchronously
/// </summary>
/// <param name="limit">The maximum number of models to return in each page.</param>
/// <returns>An asynchronous enumerable that yields the response as an <see cref="AnthropicResult{T}"/> where T is <see cref="Page{T}"/> where T is <see cref="AnthropicModel"/>.</returns>
///
IAsyncEnumerable<AnthropicResult<Page<AnthropicModel>>> ListAllModelsAsync(int limit = 20);
/// <summary>
/// Gets a model by its ID asynchronously.
/// </summary>
/// <param name="modelId">The ID of the model to get.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the response as an <see cref="AnthropicResult{T}"/> where T is <see cref="AnthropicModel"/>.</returns>
Task<AnthropicResult<AnthropicModel>> GetModelAsync(string modelId);
} }
/// <inheritdoc cref="IAnthropicApiClient"/> /// <inheritdoc cref="IAnthropicApiClient"/>
@@ -42,6 +65,7 @@ public class AnthropicApiClient : IAnthropicApiClient
private const string ApiKeyHeader = "x-api-key"; private const string ApiKeyHeader = "x-api-key";
private const string MessagesEndpoint = "messages"; private const string MessagesEndpoint = "messages";
private const string CountTokensEndpoint = "messages/count_tokens"; private const string CountTokensEndpoint = "messages/count_tokens";
private const string ModelsEndpoint = "models";
private const string JsonContentType = "application/json"; private const string JsonContentType = "application/json";
private const string EventPrefix = "event:"; private const string EventPrefix = "event:";
private const string DataPrefix = "data:"; private const string DataPrefix = "data:";
@@ -76,7 +100,7 @@ public class AnthropicApiClient : IAnthropicApiClient
} }
} }
/// <inheritdoc /> /// <inheritdoc/>
public async Task<AnthropicResult<MessageResponse>> CreateMessageAsync(MessageRequest request) public async Task<AnthropicResult<MessageResponse>> CreateMessageAsync(MessageRequest request)
{ {
var response = await SendRequestAsync(MessagesEndpoint, request); var response = await SendRequestAsync(MessagesEndpoint, request);
@@ -99,7 +123,7 @@ public class AnthropicApiClient : IAnthropicApiClient
return AnthropicResult<MessageResponse>.Success(msgResponse, anthropicHeaders); return AnthropicResult<MessageResponse>.Success(msgResponse, anthropicHeaders);
} }
/// <inheritdoc /> /// <inheritdoc/>
public async IAsyncEnumerable<AnthropicEvent> CreateMessageAsync(StreamMessageRequest request) public async IAsyncEnumerable<AnthropicEvent> CreateMessageAsync(StreamMessageRequest request)
{ {
var response = await SendRequestAsync(MessagesEndpoint, request); var response = await SendRequestAsync(MessagesEndpoint, request);
@@ -263,7 +287,7 @@ public class AnthropicApiClient : IAnthropicApiClient
} while (true); } while (true);
} }
/// <inheritdoc /> /// <inheritdoc/>
public async Task<AnthropicResult<TokenCountResponse>> CountMessageTokensAsync(CountMessageTokensRequest request) public async Task<AnthropicResult<TokenCountResponse>> CountMessageTokensAsync(CountMessageTokensRequest request)
{ {
var response = await SendRequestAsync(CountTokensEndpoint, request); var response = await SendRequestAsync(CountTokensEndpoint, request);
@@ -277,10 +301,82 @@ public class AnthropicApiClient : IAnthropicApiClient
} }
var msgResponse = Deserialize<TokenCountResponse>(responseContent) ?? new TokenCountResponse(); var msgResponse = Deserialize<TokenCountResponse>(responseContent) ?? new TokenCountResponse();
return AnthropicResult<TokenCountResponse>.Success(msgResponse, anthropicHeaders); return AnthropicResult<TokenCountResponse>.Success(msgResponse, anthropicHeaders);
} }
/// <inheritdoc/>
public async Task<AnthropicResult<Page<AnthropicModel>>> 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<AnthropicError>(responseContent) ?? new AnthropicError();
return AnthropicResult<Page<AnthropicModel>>.Failure(error, anthropicHeaders);
}
var page = Deserialize<Page<AnthropicModel>>(responseContent) ?? new Page<AnthropicModel>();
return AnthropicResult<Page<AnthropicModel>>.Success(page, anthropicHeaders);
}
/// <inheritdoc/>
public async IAsyncEnumerable<AnthropicResult<Page<AnthropicModel>>> 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<AnthropicError>(responseContent) ?? new AnthropicError();
yield return AnthropicResult<Page<AnthropicModel>>.Failure(error, anthropicHeaders);
yield break;
}
var page = Deserialize<Page<AnthropicModel>>(responseContent) ?? new Page<AnthropicModel>();
if (page.HasMore && page.LastId is not null)
{
hasMore = true;
pagingRequest = new PagingRequest(limit: limit, afterId: page.LastId);
}
else
{
hasMore = false;
}
yield return AnthropicResult<Page<AnthropicModel>>.Success(page, anthropicHeaders);
} while (hasMore);
}
/// <inheritdoc/>
public async Task<AnthropicResult<AnthropicModel>> 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<AnthropicError>(responseContent) ?? new AnthropicError();
return AnthropicResult<AnthropicModel>.Failure(error, anthropicHeaders);
}
var model = Deserialize<AnthropicModel>(responseContent) ?? new AnthropicModel();
return AnthropicResult<AnthropicModel>.Success(model, anthropicHeaders);
}
private ToolCall? GetToolCall(MessageResponse response, List<Tool> tools) private ToolCall? GetToolCall(MessageResponse response, List<Tool> tools)
{ {
var toolUse = response.Content.OfType<ToolUseContent>().FirstOrDefault(); var toolUse = response.Content.OfType<ToolUseContent>().FirstOrDefault();
@@ -300,6 +396,11 @@ public class AnthropicApiClient : IAnthropicApiClient
return new ToolCall(tool, toolUse); return new ToolCall(tool, toolUse);
} }
private async Task<HttpResponseMessage> SendRequestAsync(string endpoint)
{
return await _httpClient.GetAsync(endpoint);
}
private async Task<HttpResponseMessage> SendRequestAsync<T>(string endpoint, T request) private async Task<HttpResponseMessage> SendRequestAsync<T>(string endpoint, T request)
{ {
var requestJson = Serialize(request); var requestJson = Serialize(request);
@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
/// <summary>
/// Represents an Anthropic model.
/// </summary>
public class AnthropicModel
{
/// <summary>
/// The type of the model.
/// </summary>
public string Type { get; init; } = string.Empty;
/// <summary>
/// The id of the model.
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// The display name of the model.
/// </summary>
[JsonPropertyName("display_name")]
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// The created date of the model.
/// </summary>
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; init; }
}
+38
View File
@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
/// <summary>
/// Represents a page.
/// </summary>
public class Page
{
/// <summary>
/// The id of the first item in the page.
/// </summary>
[JsonPropertyName("first_id")]
public string? FirstId { get; init; } = string.Empty;
/// <summary>
/// The id of the last item in the page.
/// </summary>
[JsonPropertyName("last_id")]
public string? LastId { get; init; } = string.Empty;
/// <summary>
/// Indicates whether there is more data to be retrieved.
/// </summary>
[JsonPropertyName("has_more")]
public bool HasMore { get; init; }
}
/// <summary>
/// Represents a page with data.
/// </summary>
public class Page<T> : Page
{
/// <summary>
/// The data in the page.
/// </summary>
public T[] Data { get; init; } = [];
}
@@ -0,0 +1,83 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
/// <summary>
/// Represents a request to page through a collection of items.
/// </summary>
public class PagingRequest
{
private const int LimitMinimum = 1;
private const int LimitMaximum = 1000;
private const int DefaultLimit = 20;
/// <summary>
/// The ID of the item before which to start the page.
/// </summary>
[JsonPropertyName("before_id")]
public string BeforeId { get; init; }
/// <summary>
/// The ID of the item after which to start the page.
/// </summary>
[JsonPropertyName("after_id")]
public string AfterId { get; init; }
/// <summary>
/// The maximum number of items to return in the page.
/// </summary>
public int Limit { get; init; }
/// <summary>
/// Initializes a new instance of the <see cref="PagingRequest"/> class.
/// </summary>
/// <param name="beforeId">The ID of the item before which to start the page.</param>
/// <param name="afterId">The ID of the item after which to start the page.</param>
/// <param name="limit">The maximum number of items to return in the page.</param>
/// <exception cref="ArgumentException">Thrown when both <paramref name="beforeId"/> and <paramref name="afterId"/> are specified.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="limit"/> is less than 1 or greater than 1000.</exception>
/// <returns>A new instance of the <see cref="PagingRequest"/> class.</returns>
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;
}
/// <summary>
/// Converts the <see cref="PagingRequest"/> to a query string.
/// </summary>
/// <returns>The query string representation of the <see cref="PagingRequest"/>.</returns>
public string ToQueryParameters()
{
var parameters = new List<string>();
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);
}
}
@@ -303,4 +303,42 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf
result.Value.Should().BeOfType<TokenCountResponse>(); result.Value.Should().BeOfType<TokenCountResponse>();
result.Value.InputTokens.Should().BeGreaterThan(0); 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<Page<AnthropicModel>>();
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<Page<AnthropicModel>>();
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<AnthropicModel>();
result.Value.Id.Should().Be(AnthropicModels.Claude3Haiku);
}
} }
@@ -57,7 +57,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10, ""input_tokens"": 10,
""output_tokens"": 25 ""output_tokens"": 25
} }
}" }"
); );
var request = new MessageRequest( var request = new MessageRequest(
@@ -115,7 +115,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10, ""input_tokens"": 10,
""output_tokens"": 25 ""output_tokens"": 25
} }
}" }"
); );
var func = (string ticker) => ticker; var func = (string ticker) => ticker;
@@ -191,7 +191,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10, ""input_tokens"": 10,
""output_tokens"": 25 ""output_tokens"": 25
} }
}" }"
); );
var request = new MessageRequest( var request = new MessageRequest(
@@ -331,12 +331,12 @@ public class AnthropicApiClientTests : IntegrationTest
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
"application/json", "application/json",
@"{ @"{
""type"": ""error"", ""type"": ""error"",
""error"": { ""error"": {
""type"": ""invalid_request_error"", ""type"": ""invalid_request_error"",
""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row"" ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
} }
}" }"
); );
var request = new StreamMessageRequest( var request = new StreamMessageRequest(
@@ -385,7 +385,7 @@ public class AnthropicApiClientTests : IntegrationTest
""input_tokens"": 10, ""input_tokens"": 10,
""output_tokens"": 25 ""output_tokens"": 25
} }
}" }"
); );
var request = new MessageRequest( var request = new MessageRequest(
@@ -428,8 +428,8 @@ public class AnthropicApiClientTests : IntegrationTest
HttpStatusCode.OK, HttpStatusCode.OK,
"application/json", "application/json",
@"{ @"{
""input_tokens"": 10 ""input_tokens"": 10
}" }"
); );
var request = new CountMessageTokensRequest( var request = new CountMessageTokensRequest(
@@ -455,12 +455,12 @@ public class AnthropicApiClientTests : IntegrationTest
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
"application/json", "application/json",
@"{ @"{
""type"": ""error"", ""type"": ""error"",
""error"": { ""error"": {
""type"": ""invalid_request_error"", ""type"": ""invalid_request_error"",
""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row"" ""message"": ""messages: roles must alternate between user and assistant, but found multiple user roles in a row""
} }
}" }"
); );
var request = new CountMessageTokensRequest( var request = new CountMessageTokensRequest(
@@ -479,7 +479,7 @@ public class AnthropicApiClientTests : IntegrationTest
} }
[Fact] [Fact]
public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotDeserializeError_ItShouldReturnUnknownError()
{ {
_mockHttpMessageHandler _mockHttpMessageHandler
.WhenCountMessageTokensRequest() .WhenCountMessageTokensRequest()
@@ -503,4 +503,479 @@ public class AnthropicApiClientTests : IntegrationTest
result.Error.Should().BeOfType<AnthropicError>(); result.Error.Should().BeOfType<AnthropicError>();
result.Error.Error.Should().BeOfType<ApiError>(); result.Error.Error.Should().BeOfType<ApiError>();
} }
[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<Page<AnthropicModel>>();
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<string, string>
{
{ "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<Page<AnthropicModel>>();
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<Page<AnthropicModel>>();
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<AnthropicError>();
result.Error.Error.Should().BeOfType<InvalidRequestError>();
}
[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<AnthropicError>();
result.Error.Error.Should().BeOfType<ApiError>();
}
[Fact]
public async Task ListAllModelsAsync_WhenCalled_ItShouldReturnAllModels()
{
_mockHttpMessageHandler
.WhenListModelsRequest()
.WithExactQueryString(new Dictionary<string, string>()
{
{ "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<string, string>()
{
{ "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<Page<AnthropicModel>>();
await foreach (var response in pageResponses)
{
response.IsSuccess.Should().BeTrue();
response.Value.Should().BeOfType<Page<AnthropicModel>>();
collectedPages.Add(response.Value);
}
collectedPages.Should().HaveCount(2);
collectedPages.Should().BeEquivalentTo(new Page<AnthropicModel>[]
{
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<AnthropicError>();
page.Error.Error.Should().BeOfType<InvalidRequestError>();
}
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<AnthropicError>();
page.Error.Error.Should().BeOfType<ApiError>();
}
count.Should().Be(1);
}
[Fact]
public async Task ListAllModelsAsync_WhenFirstPageSucceedsAndSecondPageFails_ItShouldReturnFirstPageAndError()
{
_mockHttpMessageHandler
.WhenListModelsRequest()
.WithExactQueryString(new Dictionary<string, string>
{
{ "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<string, string>
{
{ "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<Page<AnthropicModel>>();
}
else
{
page.IsSuccess.Should().BeFalse();
page.Error.Should().BeOfType<AnthropicError>();
page.Error.Error.Should().BeOfType<InvalidRequestError>();
}
}
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<AnthropicModel>();
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<AnthropicError>();
result.Error.Error.Should().BeOfType<InvalidRequestError>();
}
[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<AnthropicError>();
result.Error.Error.Should().BeOfType<ApiError>();
}
[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<AnthropicModel>();
}
} }
@@ -16,6 +16,7 @@ public static class MockHttpMessageHandlerExtensions
private const string BaseUrl = "https://api.anthropic.com/v1"; private const string BaseUrl = "https://api.anthropic.com/v1";
private static readonly string MessagesEndpoint = $"{BaseUrl}/messages"; private static readonly string MessagesEndpoint = $"{BaseUrl}/messages";
private static readonly string CountTokensEndpoint = $"{BaseUrl}/messages/count_tokens"; private static readonly string CountTokensEndpoint = $"{BaseUrl}/messages/count_tokens";
private static readonly string ModelsEndpoint = $"{BaseUrl}/models";
private static MockedRequest SetupBaseRequest( private static MockedRequest SetupBaseRequest(
this MockHttpMessageHandler mockHttpMessageHandler, this MockHttpMessageHandler mockHttpMessageHandler,
@@ -51,4 +52,16 @@ public static class MockHttpMessageHandlerExtensions
return mockHttpMessageHandler return mockHttpMessageHandler
.SetupBaseRequest(HttpMethod.Post, CountTokensEndpoint); .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}");
}
} }
@@ -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<AnthropicModel>(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);
}
}
@@ -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<string>();
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<Page>(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<Page<string>>(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<string>
{
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<Page<string>>(EmptyPageJson);
result.Should().NotBeNull();
result!.FirstId.Should().BeEmpty();
result.LastId.Should().BeEmpty();
result.HasMore.Should().BeFalse();
result.Data.Should().BeEmpty();
}
}
@@ -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<ArgumentOutOfRangeException>().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<ArgumentOutOfRangeException>().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<ArgumentException>().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");
}
}