From 31a7097cdc9f9f60a7b6576b8bcee7a49a00552c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Jan 2025 20:12:26 -0600 Subject: [PATCH 1/6] fix: remove model id validation --- src/AnthropicClient/Models/BaseMessageRequest.cs | 6 ------ src/AnthropicClient/Models/MessageRequest.cs | 1 - src/AnthropicClient/Models/StreamMessageRequest.cs | 1 - .../Unit/Models/MessageRequestTests.cs | 4 ++-- .../Unit/Models/StreamMessageRequestTests.cs | 4 ++-- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/AnthropicClient/Models/BaseMessageRequest.cs b/src/AnthropicClient/Models/BaseMessageRequest.cs index 04ac289..03a4809 100644 --- a/src/AnthropicClient/Models/BaseMessageRequest.cs +++ b/src/AnthropicClient/Models/BaseMessageRequest.cs @@ -126,7 +126,6 @@ public abstract class BaseMessageRequest /// A value indicating whether the message should be streamed. /// The prompt stop sequences. /// The system messages to use for the request. - /// Thrown when the model ID is invalid. /// Thrown when the model or messages is null. /// Thrown when the messages contain no messages. /// Thrown when the max tokens is less than one. @@ -151,11 +150,6 @@ public abstract class BaseMessageRequest ArgumentValidator.ThrowIfNull(model, nameof(model)); ArgumentValidator.ThrowIfNull(messages, nameof(messages)); - if (AnthropicModels.IsValidModel(model) is false) - { - throw new ArgumentException($"Invalid model ID: {model}"); - } - if (messages.Count < 1) { throw new ArgumentException("Messages must contain at least one message"); diff --git a/src/AnthropicClient/Models/MessageRequest.cs b/src/AnthropicClient/Models/MessageRequest.cs index f091dbf..d3f8771 100644 --- a/src/AnthropicClient/Models/MessageRequest.cs +++ b/src/AnthropicClient/Models/MessageRequest.cs @@ -25,7 +25,6 @@ public class MessageRequest : BaseMessageRequest /// The tools to use for the request. /// The prompt stop sequences. /// The system messages to include with the request. - /// Thrown when the model ID is invalid. /// Thrown when the model or messages is null. /// Thrown when the messages contain no messages. /// Thrown when the max tokens is less than one. diff --git a/src/AnthropicClient/Models/StreamMessageRequest.cs b/src/AnthropicClient/Models/StreamMessageRequest.cs index 402075e..f07c952 100644 --- a/src/AnthropicClient/Models/StreamMessageRequest.cs +++ b/src/AnthropicClient/Models/StreamMessageRequest.cs @@ -25,7 +25,6 @@ public class StreamMessageRequest : BaseMessageRequest /// The tools to use for the request. /// The prompt stop sequences. /// The system messages to include with the request. - /// Thrown when the model ID is invalid. /// Thrown when the model or messages is null. /// Thrown when the messages contain no messages. /// Thrown when the max tokens is less than one. diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs index 2efd402..2e7e87c 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs @@ -232,14 +232,14 @@ public class MessageRequestTests : SerializationTest } [Fact] - public void Constructor_WhenCalledAndModelIsInvalid_ItShouldThrowArgumentException() + public void Constructor_WhenCalledAndModelIsInvalid_ItShouldNotThrowException() { var action = () => new MessageRequest( model: "invalid-model", messages: [new()] ); - action.Should().Throw(); + action.Should().NotThrow(); } [Fact] diff --git a/tests/AnthropicClient.Tests/Unit/Models/StreamMessageRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/StreamMessageRequestTests.cs index 67ebd1e..e09982f 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/StreamMessageRequestTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/StreamMessageRequestTests.cs @@ -85,14 +85,14 @@ public class StreamMessageRequestTests : SerializationTest } [Fact] - public void Constructor_WhenCalledAndModelIsInvalid_ItShouldThrowArgumentException() + public void Constructor_WhenCalledAndModelIsInvalid_ItShouldNotThrowException() { var action = () => new StreamMessageRequest( model: "invalid-model", messages: [new()] ); - action.Should().Throw(); + action.Should().NotThrow(); } [Fact] From 944822060cd297c5720d8a1dc366f08ca0ec79ec Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 1 Jan 2025 20:13:01 -0600 Subject: [PATCH 2/6] feat: add support for count tokens endpoint --- src/AnthropicClient/AnthropicApiClient.cs | 34 ++++- src/AnthropicClient/Models/AnthropicModels.cs | 17 --- .../Models/CountMessageTokensRequest.cs | 72 +++++++++++ .../Models/TokenCountResponse.cs | 15 +++ .../EndToEnd/AnthropicApiClientTests.cs | 17 +++ .../Unit/Models/AnthropicModelsTests.cs | 18 --- .../Models/CountMessageTokensRequestTests.cs | 117 ++++++++++++++++++ .../Unit/Models/TokenCountResponseTests.cs | 46 +++++++ 8 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 src/AnthropicClient/Models/CountMessageTokensRequest.cs create mode 100644 src/AnthropicClient/Models/TokenCountResponse.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CountMessageTokensRequestTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/TokenCountResponseTests.cs diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 15736f1..faa1353 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -26,6 +26,13 @@ public interface IAnthropicApiClient /// The message request to create. /// An asynchronous enumerable that yields the response event by event. IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request); + + /// + /// Counts the tokens in a message asynchronously. + /// + /// 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); } /// @@ -34,6 +41,7 @@ public class AnthropicApiClient : IAnthropicApiClient private const string BaseUrl = "https://api.anthropic.com/v1/"; private const string ApiKeyHeader = "x-api-key"; private const string MessagesEndpoint = "messages"; + private const string CountTokensEndpoint = "messages/count_tokens"; private const string JsonContentType = "application/json"; private const string EventPrefix = "event:"; private const string DataPrefix = "data:"; @@ -71,7 +79,7 @@ public class AnthropicApiClient : IAnthropicApiClient /// public async Task> CreateMessageAsync(MessageRequest request) { - var response = await SendRequestAsync(request); + var response = await SendRequestAsync(MessagesEndpoint, request); var anthropicHeaders = new AnthropicHeaders(response.Headers); var responseContent = await response.Content.ReadAsStringAsync(); @@ -94,7 +102,7 @@ public class AnthropicApiClient : IAnthropicApiClient /// public async IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request) { - var response = await SendRequestAsync(request); + var response = await SendRequestAsync(MessagesEndpoint, request); if (response.IsSuccessStatusCode is false) { @@ -274,11 +282,29 @@ public class AnthropicApiClient : IAnthropicApiClient return new ToolCall(tool, toolUse); } - private async Task SendRequestAsync(BaseMessageRequest request) + /// + public async Task> 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(responseContent) ?? new AnthropicError(); + return AnthropicResult.Failure(error, anthropicHeaders); + } + + var msgResponse = Deserialize(responseContent) ?? new TokenCountResponse(); + + return AnthropicResult.Success(msgResponse, anthropicHeaders); + } + + private async Task SendRequestAsync(string endpoint, T request) { var requestJson = Serialize(request); var requestContent = new StringContent(requestJson, Encoding.UTF8, JsonContentType); - return await _httpClient.PostAsync(MessagesEndpoint, requestContent); + return await _httpClient.PostAsync(endpoint, requestContent); } private string Serialize(T obj) => JsonSerializer.Serialize(obj, JsonSerializationOptions.DefaultOptions); diff --git a/src/AnthropicClient/Models/AnthropicModels.cs b/src/AnthropicClient/Models/AnthropicModels.cs index f5165d9..158a5a9 100644 --- a/src/AnthropicClient/Models/AnthropicModels.cs +++ b/src/AnthropicClient/Models/AnthropicModels.cs @@ -69,21 +69,4 @@ public static class AnthropicModels /// The Claude 3.5 Haiku model. /// 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; } \ No newline at end of file diff --git a/src/AnthropicClient/Models/CountMessageTokensRequest.cs b/src/AnthropicClient/Models/CountMessageTokensRequest.cs new file mode 100644 index 0000000..6779121 --- /dev/null +++ b/src/AnthropicClient/Models/CountMessageTokensRequest.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a request to count the number of tokens in a message. +/// +public class CountMessageTokensRequest +{ + /// + /// Gets the model ID to be used for the request. + /// + public string Model { get; init; } = string.Empty; + + /// + /// Gets the messages to count the number of tokens in. + /// + public List Messages { get; init; } = []; + + /// + /// Gets the tool choice mode to use for the request. + /// + [JsonPropertyName("tool_choice")] + public ToolChoice? ToolChoice { get; init; } = null; + + /// + /// Gets the tools to use for the request. + /// + public List? Tools { get; init; } = null; + + /// + /// Gets the system prompt to use for the request. + /// + [JsonPropertyName("system")] + public List? SystemPrompt { get; init; } = null; + + /// + /// Initializes a new instance of the class. + /// + /// The model ID to use for the request. + /// The messages to count the number of tokens in. + /// The tool choice mode to use for the request. + /// The tools to use for the request. + /// The system prompt to use for the request. + /// Thrown when or is null. + /// Thrown when is empty. + /// A new instance of the class. + public CountMessageTokensRequest( + string model, + List messages, + ToolChoice? toolChoice = null, + List? tools = null, + List? 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; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/TokenCountResponse.cs b/src/AnthropicClient/Models/TokenCountResponse.cs new file mode 100644 index 0000000..ec532de --- /dev/null +++ b/src/AnthropicClient/Models/TokenCountResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a response to a token count request. +/// +public class TokenCountResponse +{ + /// + /// The number of input tokens counted. + /// + [JsonPropertyName("input_tokens")] + public int InputTokens { get; init; } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index d66e39e..75040e8 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -299,4 +299,21 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf resultTwo.Value.Content.Should().NotBeNullOrEmpty(); 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(); + result.Value.InputTokens.Should().BeGreaterThan(0); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs index 396edbd..3b2343a 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs @@ -131,22 +131,4 @@ public class AnthropicModelsTests 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); - } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/CountMessageTokensRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CountMessageTokensRequestTests.cs new file mode 100644 index 0000000..2f9b868 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CountMessageTokensRequestTests.cs @@ -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 { new() }; + var systemPrompt = new List() { new("test-system") }; + var toolChoice = new AutoToolChoice(); + var tools = new List(); + + 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(); + } + + [Fact] + public void Constructor_WhenCalledAndMessagesIsNull_ItShouldThrowArgumentNullException() + { + var action = () => new CountMessageTokensRequest( + model: AnthropicModels.Claude3Sonnet, + messages: null! + ); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndMessagesIsEmpty_ItShouldThrowArgumentException() + { + var action = () => new CountMessageTokensRequest( + model: AnthropicModels.Claude3Sonnet, + messages: [] + ); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var messages = new List() + { + new() + { + Role = MessageRole.User, + Content = [new TextContent("Hello!")] + } + }; + + var model = AnthropicModels.Claude3Sonnet; + var systemPrompt = new List() { new("test-system") }; + var toolChoice = new AutoToolChoice(); + var tools = new List(); + + 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(_testJson); + + request!.Model.Should().Be(AnthropicModels.Claude3Sonnet); + request.SystemPrompt.Should().BeEquivalentTo(new List { new("test-system") }); + request.Messages.Should().HaveCount(1); + request.ToolChoice.Should().BeOfType(); + request.ToolChoice!.Type.Should().Be("auto"); + request.Tools.Should().HaveCount(0); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/TokenCountResponseTests.cs b/tests/AnthropicClient.Tests/Unit/Models/TokenCountResponseTests.cs new file mode 100644 index 0000000..21f2c38 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/TokenCountResponseTests.cs @@ -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(json); + + response!.InputTokens.Should().Be(1); + } +} \ No newline at end of file From 4fe8bfaf0e1776b2bfab95856ba92aa6c3fc1777 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Jan 2025 20:40:38 -0600 Subject: [PATCH 3/6] refactor: put public method above private methods --- src/AnthropicClient/AnthropicApiClient.cs | 36 +++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index faa1353..48ea518 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -263,6 +263,24 @@ public class AnthropicApiClient : IAnthropicApiClient } while (true); } + /// + public async Task> 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(responseContent) ?? new AnthropicError(); + return AnthropicResult.Failure(error, anthropicHeaders); + } + + var msgResponse = Deserialize(responseContent) ?? new TokenCountResponse(); + + return AnthropicResult.Success(msgResponse, anthropicHeaders); + } + private ToolCall? GetToolCall(MessageResponse response, List tools) { var toolUse = response.Content.OfType().FirstOrDefault(); @@ -282,24 +300,6 @@ public class AnthropicApiClient : IAnthropicApiClient return new ToolCall(tool, toolUse); } - /// - public async Task> 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(responseContent) ?? new AnthropicError(); - return AnthropicResult.Failure(error, anthropicHeaders); - } - - var msgResponse = Deserialize(responseContent) ?? new TokenCountResponse(); - - return AnthropicResult.Success(msgResponse, anthropicHeaders); - } - private async Task SendRequestAsync(string endpoint, T request) { var requestJson = Serialize(request); From a23938961f20724f1eda43cbe3b0a21ae594b8e9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Jan 2025 20:41:12 -0600 Subject: [PATCH 4/6] =?UTF-8?q?tests:=20add=20integration=20tests=20to=20c?= =?UTF-8?q?over=20both=20=F0=9F=98=81=20and=20=F0=9F=A5=B2=20paths=20when?= =?UTF-8?q?=20counting=20message=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Integration/AnthropicApiClientTests.cs | 85 +++++++++++++++++++ .../Integration/IntegrationTest.cs | 22 ++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs index 55a22e2..f874a31 100644 --- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs @@ -418,4 +418,89 @@ public class AnthropicApiClientTests : IntegrationTest textContent.As().Text.Should().Be("It is a PDF"); textContent.As().Type.Should().Be("text"); } + + [Fact] + public async Task CountMessageTokensAsync_WhenCalled_ItShouldReturnCountTokensResponse() + { + _mockHttpMessageHandler + .WhenCountMessageTokensRequest() + .Respond( + HttpStatusCode.OK, + "application/json", + @"{ + ""input_tokens"": 10 + }" + ); + + var request = new CountMessageTokensRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [new TextContent("Hello!")]), + ] + ); + + var result = await Client.CountMessageTokensAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + result.Value.InputTokens.Should().Be(10); + } + + [Fact] + public async Task CountMessageTokensAsync_WhenCalledAndErrorReturned_ItShouldHandleError() + { + _mockHttpMessageHandler + .WhenCountMessageTokensRequest() + .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 request = new CountMessageTokensRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [new TextContent("Hello!")]), + new(MessageRole.User, [new TextContent("Hello!")]) + ] + ); + + var result = await Client.CountMessageTokensAsync(request); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } + + [Fact] + public async Task CountMessageTokensAsync_WhenCalledRequestFailsAndCanNotSerializeError_ItShouldReturnUnknownError() + { + _mockHttpMessageHandler + .WhenCountMessageTokensRequest() + .Respond( + HttpStatusCode.BadRequest, + "application/json", + @"{}" + ); + + var request = new CountMessageTokensRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [new TextContent("Hello!")]), + new(MessageRole.User, [new TextContent("Hello!")]) + ] + ); + + var result = await Client.CountMessageTokensAsync(request); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().BeOfType(); + result.Error.Error.Should().BeOfType(); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs index efc74a9..fd04cec 100644 --- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs +++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs @@ -13,10 +13,18 @@ public class IntegrationTest public static class MockHttpMessageHandlerExtensions { - private static MockedRequest SetupBaseRequest(this MockHttpMessageHandler mockHttpMessageHandler) + 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 MockedRequest SetupBaseRequest( + this MockHttpMessageHandler mockHttpMessageHandler, + HttpMethod method, + string url + ) { return mockHttpMessageHandler - .When(HttpMethod.Post, "https://api.anthropic.com/v1/messages") + .When(method, url) .WithHeaders(new Dictionary { { "anthropic-version", "2023-06-01" }, @@ -27,14 +35,20 @@ public static class MockHttpMessageHandlerExtensions public static MockedRequest WhenCreateMessageRequest(this MockHttpMessageHandler mockHttpMessageHandler) { return mockHttpMessageHandler - .SetupBaseRequest() + .SetupBaseRequest(HttpMethod.Post, MessagesEndpoint) .WithJsonContent(r => r.Stream == false, JsonSerializationOptions.DefaultOptions); } public static MockedRequest WhenCreateStreamMessageRequest(this MockHttpMessageHandler mockHttpMessageHandler) { return mockHttpMessageHandler - .SetupBaseRequest() + .SetupBaseRequest(HttpMethod.Post, MessagesEndpoint) .WithJsonContent(r => r.Stream == true, JsonSerializationOptions.DefaultOptions); } + + public static MockedRequest WhenCountMessageTokensRequest(this MockHttpMessageHandler mockHttpMessageHandler) + { + return mockHttpMessageHandler + .SetupBaseRequest(HttpMethod.Post, CountTokensEndpoint); + } } \ No newline at end of file From 57f66d36195a0b9e3ee31758e2f346d7a5d78614 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Jan 2025 20:47:35 -0600 Subject: [PATCH 5/6] chore: remove need for beta header when using prompt caching or PDF support --- README.md | 39 +------------------ .../EndToEnd/AnthropicApiClientTests.cs | 25 +++--------- 2 files changed, 8 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index a03e6a3..e5a3580 100644 --- a/README.md +++ b/README.md @@ -694,22 +694,7 @@ foreach (var content in response.Value.Content) ### Prompt Caching -Anthropic has recently introduced a feature called [Prompt Caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) that allows you to cache all or part of the prompt you send to the model. This can be used to improve the performance of your application by reducing latency and token usage. This feature is covered in depth in [Anthropic's API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching). - -> [!NOTE] -> This feature is in beta and requires you to set an `anthropic-beta` header on your requests to use it. -> The value of the header should be `prompt-caching-2024-07-31`. - -When using this library you can opt-in to prompt caching by adding the required header to the `HttpClient` instance you provide to the `AnthropicApiClient` constructor. - -```csharp -using AnthropicClient; - -var httpClient = new HttpClient(); -httpClient.DefaultRequestHeaders.Add("anthropic-beta", "prompt-caching-2024-07-31"); - -var client = new AnthropicApiClient(apiKey, httpClient); -``` +Anthropic provides a feature called [Prompt Caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) that allows you to cache all or part of the prompt you send to the model. This can be used to improve the performance of your application by reducing latency and token usage. This feature is covered in depth in [Anthropic's API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching). Prompt caching can be used to cache all parts of the prompt including system messages, user messages, and tools. You should refer to the [Anthropic API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) for specifics on limitations and requirements for using prompt caching. This library aims to make using prompt caching convenient and give you complete control over what parts of the prompt are cached. Currently there is only one type of cache control available - `EphemeralCacheControl`. @@ -850,22 +835,7 @@ foreach (var content in response.Value.Content) ### PDF Support -Anthropic has recently introduced a feature called [PDF Support](https://docs.anthropic.com/en/docs/build-with-claude/pdf-support) that allows Claude to support PDF input and understand both text and visual content within documents. . This feature is covered in depth in [Anthropic's API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/pdf-support). - -> [!NOTE] -> This feature is in beta and requires you to set an `anthropic-beta` header on your requests to use it. -> The value of the header should be `pdfs-2024-09-25`. - -When using this library you can opt-in to PDF support by adding the required header to the `HttpClient` instance you provide to the `AnthropicApiClient` constructor. - -```csharp -using AnthropicClient; - -var httpClient = new HttpClient(); -httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25"); - -var client = new AnthropicApiClient(apiKey, httpClient); -``` +Anthropic provides a feature called [PDF Support](https://docs.anthropic.com/en/docs/build-with-claude/pdf-support) that allows Claude to support PDF input and understand both text and visual content within documents. This feature is covered in depth in [Anthropic's API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/pdf-support). PDF support can be used to provide a PDF document as input to the model. This can be used to provide additional context to the model or to ask for additional information from the model. This library aims to make using PDF support convenient by allowing you to provide the PDF document you want Anthropic's models to consider for use when creating a message. @@ -885,11 +855,6 @@ var request = new MessageRequest( ] ); -var httpClient = new HttpClient(); -httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25"); - -var client = new AnthropicApiClient(apiKey, httpClient); - var response = await client.CreateMessageAsync(request); if (response.IsSuccess is false) diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 75040e8..50dab64 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -100,10 +100,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf [Fact] public async Task CreateMessageAsync_WhenSystemMessagesContainCacheControl_ItShouldUseCache() { - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("anthropic-beta", "prompt-caching-2024-07-31"); - - var client = CreateClient(httpClient); + var client = CreateClient(new HttpClient()); var storyPath = GetTestFilePath("story.txt"); var storyText = await File.ReadAllTextAsync(storyPath); @@ -121,7 +118,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf ] ); - var resultOne = await client.CreateMessageAsync(request); + var resultOne = await _client.CreateMessageAsync(request); resultOne.IsSuccess.Should().BeTrue(); resultOne.Value.Should().BeOfType(); @@ -142,10 +139,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf [Fact] public async Task CreateMessageAsync_WhenMessagesContainCacheControl_ItShouldUseCache() { - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("anthropic-beta", "prompt-caching-2024-07-31"); - - var client = CreateClient(httpClient); + var client = CreateClient(new HttpClient()); var storyPath = GetTestFilePath("story.txt"); var storyText = await File.ReadAllTextAsync(storyPath); @@ -181,10 +175,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf [Fact] public async Task CreateMessageAsync_WhenToolsContainCacheControl_ItShouldUseCache() { - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("anthropic-beta", "prompt-caching-2024-07-31"); - - var client = CreateClient(httpClient); + var client = CreateClient(new HttpClient()); var func = (string ticker) => ticker; @@ -238,9 +229,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf ] ); - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25"); - var client = CreateClient(httpClient); + var client = CreateClient(new HttpClient()); var result = await client.CreateMessageAsync(request); @@ -268,9 +257,7 @@ public class ClientTests(ConfigurationFixture configFixture) : EndToEndTest(conf var bytes = await File.ReadAllBytesAsync(pdfPath); var base64Data = Convert.ToBase64String(bytes); - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25, prompt-caching-2024-07-31"); - var client = CreateClient(httpClient); + var client = CreateClient(new HttpClient()); var request = new MessageRequest( model: AnthropicModels.Claude35Sonnet, From de326abe2575c2fd9c7e63aab0083fbb0b12cfa1 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Jan 2025 20:52:52 -0600 Subject: [PATCH 6/6] docs: add count message tokens example to README.md --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e5a3580..a05c5fe 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,38 @@ The primary use case for working with the Anthropic API is to create a message i > [!NOTE] > The following examples assume that you have already created an instance of the `AnthropicApiClient` class named `client`. You can also find these snippets in the examples directory. +### Count Message Tokens + +The `AnthropicApiClient` exposes a method named `CountMessageTokensAsync` that can be used to count the number of tokens in a message. The method requires a `CountMessageTokensRequest` instance as a parameter. + +```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var response = await client.CountMessageTokensAsync(new CountMessageTokensRequest( + AnthropicModels.Claude3Haiku, + [ + new( + MessageRole.User, + [new TextContent("Please write a haiku about the ocean.")] + ) + ] +)); + +if (response.IsFailure) +{ + Console.WriteLine("Failed to count message tokens"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +Console.WriteLine("Token Count: {0}", response.Value.InputTokens); +``` + ### Create a message -The `AnthropicApiClient` exposes a single 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. #### Non-Streaming