feat: add support for count tokens endpoint
This commit is contained in:
@@ -26,6 +26,13 @@ public interface IAnthropicApiClient
|
|||||||
/// <param name="request">The message request to create.</param>
|
/// <param name="request">The message request to create.</param>
|
||||||
/// <returns>An asynchronous enumerable that yields the response event by event.</returns>
|
/// <returns>An asynchronous enumerable that yields the response event by event.</returns>
|
||||||
IAsyncEnumerable<AnthropicEvent> CreateMessageAsync(StreamMessageRequest request);
|
IAsyncEnumerable<AnthropicEvent> CreateMessageAsync(StreamMessageRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts the tokens in a message asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
/// <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>
|
||||||
|
Task<AnthropicResult<TokenCountResponse>> CountMessageTokensAsync(CountMessageTokensRequest request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc cref="IAnthropicApiClient"/>
|
/// <inheritdoc cref="IAnthropicApiClient"/>
|
||||||
@@ -34,6 +41,7 @@ public class AnthropicApiClient : IAnthropicApiClient
|
|||||||
private const string BaseUrl = "https://api.anthropic.com/v1/";
|
private const string BaseUrl = "https://api.anthropic.com/v1/";
|
||||||
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 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:";
|
||||||
@@ -71,7 +79,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(request);
|
var response = await SendRequestAsync(MessagesEndpoint, request);
|
||||||
var anthropicHeaders = new AnthropicHeaders(response.Headers);
|
var anthropicHeaders = new AnthropicHeaders(response.Headers);
|
||||||
var responseContent = await response.Content.ReadAsStringAsync();
|
var responseContent = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ public class AnthropicApiClient : IAnthropicApiClient
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async IAsyncEnumerable<AnthropicEvent> CreateMessageAsync(StreamMessageRequest request)
|
public async IAsyncEnumerable<AnthropicEvent> CreateMessageAsync(StreamMessageRequest request)
|
||||||
{
|
{
|
||||||
var response = await SendRequestAsync(request);
|
var response = await SendRequestAsync(MessagesEndpoint, request);
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode is false)
|
if (response.IsSuccessStatusCode is false)
|
||||||
{
|
{
|
||||||
@@ -274,11 +282,29 @@ public class AnthropicApiClient : IAnthropicApiClient
|
|||||||
return new ToolCall(tool, toolUse);
|
return new ToolCall(tool, toolUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<HttpResponseMessage> SendRequestAsync(BaseMessageRequest request)
|
/// <inheritdoc />
|
||||||
|
public async Task<AnthropicResult<TokenCountResponse>> CountMessageTokensAsync(CountMessageTokensRequest request)
|
||||||
|
{
|
||||||
|
var response = await SendRequestAsync(CountTokensEndpoint, request);
|
||||||
|
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<TokenCountResponse>.Failure(error, anthropicHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgResponse = Deserialize<TokenCountResponse>(responseContent) ?? new TokenCountResponse();
|
||||||
|
|
||||||
|
return AnthropicResult<TokenCountResponse>.Success(msgResponse, anthropicHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> SendRequestAsync<T>(string endpoint, T request)
|
||||||
{
|
{
|
||||||
var requestJson = Serialize(request);
|
var requestJson = Serialize(request);
|
||||||
var requestContent = new StringContent(requestJson, Encoding.UTF8, JsonContentType);
|
var requestContent = new StringContent(requestJson, Encoding.UTF8, JsonContentType);
|
||||||
return await _httpClient.PostAsync(MessagesEndpoint, requestContent);
|
return await _httpClient.PostAsync(endpoint, requestContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string Serialize<T>(T obj) => JsonSerializer.Serialize(obj, JsonSerializationOptions.DefaultOptions);
|
private string Serialize<T>(T obj) => JsonSerializer.Serialize(obj, JsonSerializationOptions.DefaultOptions);
|
||||||
|
|||||||
@@ -69,21 +69,4 @@ public static class AnthropicModels
|
|||||||
/// The Claude 3.5 Haiku model.
|
/// The Claude 3.5 Haiku model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Claude35HaikuLatest = "claude-3-5-haiku-latest";
|
public const string Claude35HaikuLatest = "claude-3-5-haiku-latest";
|
||||||
|
|
||||||
internal static bool IsValidModel(string modelId) => modelId is
|
|
||||||
Claude3Opus or
|
|
||||||
Claude3Opus20241022 or
|
|
||||||
Claude3OpusLatest or
|
|
||||||
|
|
||||||
Claude3Sonnet or
|
|
||||||
Claude3Sonnet20240229 or
|
|
||||||
Claude35Sonnet or
|
|
||||||
Claude35Sonnet20240620 or
|
|
||||||
Claude35Sonnet20241022 or
|
|
||||||
Claude35SonnetLatest or
|
|
||||||
|
|
||||||
Claude3Haiku or
|
|
||||||
Claude3Haiku20240307 or
|
|
||||||
Claude35Haiku20241022 or
|
|
||||||
Claude35HaikuLatest;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using AnthropicClient.Utils;
|
||||||
|
|
||||||
|
namespace AnthropicClient.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to count the number of tokens in a message.
|
||||||
|
/// </summary>
|
||||||
|
public class CountMessageTokensRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the model ID to be used for the request.
|
||||||
|
/// </summary>
|
||||||
|
public string Model { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the messages to count the number of tokens in.
|
||||||
|
/// </summary>
|
||||||
|
public List<Message> Messages { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tool choice mode to use for the request.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("tool_choice")]
|
||||||
|
public ToolChoice? ToolChoice { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tools to use for the request.
|
||||||
|
/// </summary>
|
||||||
|
public List<Tool>? Tools { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the system prompt to use for the request.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("system")]
|
||||||
|
public List<TextContent>? SystemPrompt { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CountMessageTokensRequest"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model">The model ID to use for the request.</param>
|
||||||
|
/// <param name="messages">The messages to count the number of tokens in.</param>
|
||||||
|
/// <param name="toolChoice">The tool choice mode to use for the request.</param>
|
||||||
|
/// <param name="tools">The tools to use for the request.</param>
|
||||||
|
/// <param name="systemPrompt">The system prompt to use for the request.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="model"/> or <paramref name="messages"/> is null.</exception>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when <paramref name="messages"/> is empty.</exception>
|
||||||
|
/// <returns>A new instance of the <see cref="CountMessageTokensRequest"/> class.</returns>
|
||||||
|
public CountMessageTokensRequest(
|
||||||
|
string model,
|
||||||
|
List<Message> messages,
|
||||||
|
ToolChoice? toolChoice = null,
|
||||||
|
List<Tool>? tools = null,
|
||||||
|
List<TextContent>? systemPrompt = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ArgumentValidator.ThrowIfNull(model, nameof(model));
|
||||||
|
ArgumentValidator.ThrowIfNull(messages, nameof(messages));
|
||||||
|
|
||||||
|
if (messages.Count < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Messages must contain at least one message");
|
||||||
|
}
|
||||||
|
|
||||||
|
Model = model;
|
||||||
|
Messages = messages;
|
||||||
|
ToolChoice = toolChoice;
|
||||||
|
Tools = tools;
|
||||||
|
SystemPrompt = systemPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AnthropicClient.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a response to a token count request.
|
||||||
|
/// </summary>
|
||||||
|
public class TokenCountResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The number of input tokens counted.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("input_tokens")]
|
||||||
|
public int InputTokens { get; init; }
|
||||||
|
}
|
||||||
@@ -299,4 +299,21 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf
|
|||||||
resultTwo.Value.Content.Should().NotBeNullOrEmpty();
|
resultTwo.Value.Content.Should().NotBeNullOrEmpty();
|
||||||
resultTwo.Value.Usage.CacheReadInputTokens.Should().BeGreaterThan(0);
|
resultTwo.Value.Usage.CacheReadInputTokens.Should().BeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CountMessageTokensAsync_WhenCalled_ItShouldReturnResponse()
|
||||||
|
{
|
||||||
|
var request = new CountMessageTokensRequest(
|
||||||
|
model: AnthropicModels.Claude3Haiku,
|
||||||
|
messages: [
|
||||||
|
new(MessageRole.User, [new TextContent("Hello!")])
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await _client.CountMessageTokensAsync(request);
|
||||||
|
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Value.Should().BeOfType<TokenCountResponse>();
|
||||||
|
result.Value.InputTokens.Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -131,22 +131,4 @@ public class AnthropicModelsTests
|
|||||||
|
|
||||||
actual.Should().Be(expected);
|
actual.Should().Be(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("claude-3-opus-20240229", true)]
|
|
||||||
[InlineData("claude-3-opus-latest", true)]
|
|
||||||
[InlineData("claude-3-sonnet-20240229", true)]
|
|
||||||
[InlineData("claude-3-5-sonnet-20240620", true)]
|
|
||||||
[InlineData("claude-3-5-sonnet-20241022", true)]
|
|
||||||
[InlineData("claude-3-5-sonnet-latest", true)]
|
|
||||||
[InlineData("claude-3-haiku-20240307", true)]
|
|
||||||
[InlineData("claude-3-5-haiku-20241022", true)]
|
|
||||||
[InlineData("claude-3-5-haiku-latest", true)]
|
|
||||||
[InlineData("invalid", false)]
|
|
||||||
public void IsValidModel_WhenCalled_ItShouldReturnExpectedValue(string modelId, bool expected)
|
|
||||||
{
|
|
||||||
var actual = AnthropicModels.IsValidModel(modelId);
|
|
||||||
|
|
||||||
actual.Should().Be(expected);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
namespace AnthropicClient.Tests.Unit.Models;
|
||||||
|
|
||||||
|
public class CountMessageTokensRequestTests : SerializationTest
|
||||||
|
{
|
||||||
|
private readonly string _testJson = @"{
|
||||||
|
""model"": ""claude-3-sonnet-20240229"",
|
||||||
|
""system"": [{
|
||||||
|
""type"": ""text"",
|
||||||
|
""text"": ""test-system""
|
||||||
|
}],
|
||||||
|
""messages"": [
|
||||||
|
{ ""role"": ""user"", ""content"": [{ ""text"": ""Hello!"", ""type"": ""text"" }] }
|
||||||
|
],
|
||||||
|
""tool_choice"": { ""type"":""auto"" },
|
||||||
|
""tools"": []
|
||||||
|
}";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WhenCalled_ItShouldInitializeProperties()
|
||||||
|
{
|
||||||
|
var model = AnthropicModels.Claude3Sonnet;
|
||||||
|
var messages = new List<Message> { new() };
|
||||||
|
var systemPrompt = new List<TextContent>() { new("test-system") };
|
||||||
|
var toolChoice = new AutoToolChoice();
|
||||||
|
var tools = new List<Tool>();
|
||||||
|
|
||||||
|
var request = new CountMessageTokensRequest(
|
||||||
|
model: model,
|
||||||
|
messages: messages,
|
||||||
|
toolChoice: toolChoice,
|
||||||
|
tools: tools,
|
||||||
|
systemPrompt: systemPrompt
|
||||||
|
);
|
||||||
|
|
||||||
|
request.Model.Should().Be(model);
|
||||||
|
request.Messages.Should().BeSameAs(messages);
|
||||||
|
request.ToolChoice.Should().Be(toolChoice);
|
||||||
|
request.Tools.Should().BeSameAs(tools);
|
||||||
|
request.SystemPrompt.Should().BeSameAs(systemPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WhenCalledAndModelIsNull_ItShouldThrowArgumentNullException()
|
||||||
|
{
|
||||||
|
var action = () => new CountMessageTokensRequest(
|
||||||
|
model: null!,
|
||||||
|
messages: [new()]
|
||||||
|
);
|
||||||
|
|
||||||
|
action.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WhenCalledAndMessagesIsNull_ItShouldThrowArgumentNullException()
|
||||||
|
{
|
||||||
|
var action = () => new CountMessageTokensRequest(
|
||||||
|
model: AnthropicModels.Claude3Sonnet,
|
||||||
|
messages: null!
|
||||||
|
);
|
||||||
|
|
||||||
|
action.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WhenCalledAndMessagesIsEmpty_ItShouldThrowArgumentException()
|
||||||
|
{
|
||||||
|
var action = () => new CountMessageTokensRequest(
|
||||||
|
model: AnthropicModels.Claude3Sonnet,
|
||||||
|
messages: []
|
||||||
|
);
|
||||||
|
|
||||||
|
action.Should().Throw<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape()
|
||||||
|
{
|
||||||
|
var messages = new List<Message>()
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = [new TextContent("Hello!")]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var model = AnthropicModels.Claude3Sonnet;
|
||||||
|
var systemPrompt = new List<TextContent>() { new("test-system") };
|
||||||
|
var toolChoice = new AutoToolChoice();
|
||||||
|
var tools = new List<Tool>();
|
||||||
|
|
||||||
|
var request = new CountMessageTokensRequest(
|
||||||
|
model: model,
|
||||||
|
messages: messages,
|
||||||
|
toolChoice: toolChoice,
|
||||||
|
tools: tools,
|
||||||
|
systemPrompt: systemPrompt
|
||||||
|
);
|
||||||
|
|
||||||
|
var actual = Serialize(request);
|
||||||
|
|
||||||
|
JsonAssert.Equal(_testJson, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape()
|
||||||
|
{
|
||||||
|
var request = Deserialize<CountMessageTokensRequest>(_testJson);
|
||||||
|
|
||||||
|
request!.Model.Should().Be(AnthropicModels.Claude3Sonnet);
|
||||||
|
request.SystemPrompt.Should().BeEquivalentTo(new List<TextContent> { new("test-system") });
|
||||||
|
request.Messages.Should().HaveCount(1);
|
||||||
|
request.ToolChoice.Should().BeOfType<AutoToolChoice>();
|
||||||
|
request.ToolChoice!.Type.Should().Be("auto");
|
||||||
|
request.Tools.Should().HaveCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace AnthropicClient.Tests.Unit.Models;
|
||||||
|
|
||||||
|
public class TokenCountResponseTests : SerializationTest
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WhenCalled_ShouldInitializeProperties()
|
||||||
|
{
|
||||||
|
var expectedTokenCount = 1;
|
||||||
|
|
||||||
|
var response = new TokenCountResponse
|
||||||
|
{
|
||||||
|
InputTokens = expectedTokenCount
|
||||||
|
};
|
||||||
|
|
||||||
|
response.InputTokens.Should().Be(expectedTokenCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JsonSerialization_WhenCalled_ItShouldSerializeCorrectly()
|
||||||
|
{
|
||||||
|
var expectedJson = @"{
|
||||||
|
""input_tokens"": 1
|
||||||
|
}";
|
||||||
|
|
||||||
|
var response = new TokenCountResponse
|
||||||
|
{
|
||||||
|
InputTokens = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var actual = Serialize(response);
|
||||||
|
|
||||||
|
JsonAssert.Equal(expectedJson, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JsonDeserialization_WhenCalled_ItShouldDeserializeCorrectly()
|
||||||
|
{
|
||||||
|
var json = @"{
|
||||||
|
""input_tokens"": 1
|
||||||
|
}";
|
||||||
|
|
||||||
|
var response = Deserialize<TokenCountResponse>(json);
|
||||||
|
|
||||||
|
response!.InputTokens.Should().Be(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user