diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs new file mode 100644 index 0000000..9006d24 --- /dev/null +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -0,0 +1,233 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +using AnthropicClient.Json; +using AnthropicClient.Utils; +using AnthropicClient.Models; + +namespace AnthropicClient; + +/// +/// Represents a client for interacting with the Anthropic API. +/// +public interface IAnthropicApiClient +{ + /// + /// Creates a chat message asynchronously. + /// + /// The chat message request to create. + /// A task that represents the asynchronous operation. The task result contains the chat response as an . + Task> CreateChatMessageAsync(ChatMessageRequest request); + + /// + /// Creates a chat message asynchronously and streams the response. + /// + /// The chat message request to create. + /// An asynchronous enumerable that yields the chat response line by line. + IAsyncEnumerable CreateChatMessageAsync(StreamChatMessageRequest request); +} + +/// +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 JsonContentType = "application/json"; + private const string RequestIdHeader = "request-id"; + private const string EventPrefix = "event:"; + private const string DataPrefix = "data:"; + private readonly Dictionary _defaultHeaders = new() + { + { "anthropic-version", "2023-06-01" }, + }; + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// The API key to use for the client. + /// The HTTP client to use for the client. + /// Thrown when the API key or HTTP client is null. + /// A new instance of the class. + public AnthropicApiClient(string apiKey, HttpClient httpClient) + { + ArgumentValidator.ThrowIfNull(apiKey, nameof(apiKey)); + ArgumentValidator.ThrowIfNull(httpClient, nameof(httpClient)); + + _httpClient = httpClient; + _httpClient.BaseAddress = new Uri(BaseUrl); + _httpClient.DefaultRequestHeaders.Add(ApiKeyHeader, apiKey); + _httpClient.DefaultRequestHeaders + .Accept + .Add(new MediaTypeWithQualityHeaderValue(JsonContentType)); + + foreach (var pair in _defaultHeaders) + { + _httpClient.DefaultRequestHeaders.Add(pair.Key, pair.Value); + } + } + + /// + public async Task> CreateChatMessageAsync(ChatMessageRequest request) + { + var response = await SendRequestAsync(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 chatResponse = Deserialize(responseContent) ?? new ChatResponse(); + return AnthropicResult.Success(chatResponse, anthropicHeaders); + } + + /// + public async IAsyncEnumerable CreateChatMessageAsync(StreamChatMessageRequest request) + { + var response = await SendRequestAsync(request); + var anthropicHeaders = new AnthropicHeaders(response.Headers); + + using var responseContent = await response.Content.ReadAsStreamAsync(); + using var streamReader = new StreamReader(responseContent); + + ChatResponse? chatResponse = null; + Content? content = null; + var toolInputJsonStringBuilder = new StringBuilder(); + var currentEvent = new AnthropicEvent(); + + do + { + var line = await streamReader.ReadLineAsync(); + + // I know...this is not pretty, but here is why... + // as events are being yielded I want to also + // build up the complete chat response + // so I can yield it as a special event to make tool + // calling easier to handle + + // initialize chat response on message start + if (currentEvent.Type is EventType.MessageStart && currentEvent.Data is MessageStartEventData msgStartData) + { + chatResponse = msgStartData.Message; + } + + // initialize content block on content block start + if (currentEvent.Type is EventType.ContentBlockStart && currentEvent.Data is ContentStartEventData contentStartData) + { + content = contentStartData.ContentBlock; + } + + // update content block with deltas based on + // current content type and delta type + if (currentEvent.Type is EventType.ContentBlockDelta && currentEvent.Data is ContentDeltaEventData contentDeltaData) + { + if (content is TextContent textContent && contentDeltaData.Delta is TextDelta textDelta) + { + var newText = textContent.Text + textDelta.Text; + content = new TextContent(newText); + } + + if (content is ToolUseContent toolUseContent && contentDeltaData.Delta is JsonDelta jsonDelta) + { + toolInputJsonStringBuilder.Append(jsonDelta.PartialJson); + } + } + + // finalize content block on content block stop + // and add it to the chat response + if (currentEvent.Type is EventType.ContentBlockStop) + { + if (content is not null && chatResponse is not null) + { + if (content is TextContent textContent) + { + chatResponse.Content.Add(textContent); + } + + if (content is ToolUseContent toolUseContent) + { + var input = Deserialize>(toolInputJsonStringBuilder.ToString()); + var newToolUseContent = new ToolUseContent() + { + Id = toolUseContent.Id, + Name = toolUseContent.Name, + Input = input!, + }; + + chatResponse.Content.Add(newToolUseContent); + } + + content = null; + } + } + + // update chat response with message delta data + if ( + currentEvent.Type is EventType.MessageDelta && + currentEvent.Data is MessageDeltaEventData msgDeltaData && + chatResponse is not null + ) + { + chatResponse = new ChatResponse() + { + Id = chatResponse.Id, + Model = chatResponse.Model, + Role = chatResponse.Role, + StopReason = msgDeltaData.Delta.StopReason, + StopSequence = msgDeltaData.Delta.StopSequence, + Type = chatResponse.Type, + Usage = msgDeltaData.Usage, + Content = chatResponse.Content, + }; + } + + // yield chat response on message stop + if (currentEvent.Type is EventType.MessageStop && chatResponse is not null) + { + var eventData = new MessageCompleteEventData(chatResponse, anthropicHeaders); + yield return new AnthropicEvent(EventType.MessageComplete, eventData); + chatResponse = null; + } + + if (line is null) + { + break; + } + + if (line == string.Empty) + { + yield return currentEvent; + currentEvent = new AnthropicEvent(); + } + + if (line.StartsWith(EventPrefix)) + { + var eventType = line.Substring(EventPrefix.Length).Trim(); + currentEvent = new AnthropicEvent(eventType, currentEvent.Data); + continue; + } + + if (line.StartsWith(DataPrefix)) + { + var eventData = line.Substring(DataPrefix.Length).Trim(); + var eventDataJson = Deserialize(eventData); + currentEvent = new AnthropicEvent(currentEvent.Type, eventDataJson!); + continue; + } + } while (true); + } + + private async Task SendRequestAsync(MessageRequest request) + { + var requestContent = new StringContent(Serialize(request), Encoding.UTF8, JsonContentType); + return await _httpClient.PostAsync(MessagesEndpoint, requestContent); + } + + private string Serialize(T obj) => JsonSerializer.Serialize(obj, JsonSerializationOptions.DefaultOptions); + private T? Deserialize(string json) => JsonSerializer.Deserialize(json, JsonSerializationOptions.DefaultOptions); +} \ No newline at end of file diff --git a/src/AnthropicClient/Client.cs b/src/AnthropicClient/Client.cs deleted file mode 100644 index a3cf0b8..0000000 --- a/src/AnthropicClient/Client.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; - -using AnthropicClient.Json; -using AnthropicClient.Utils; -using AnthropicClient.Models; - -namespace AnthropicClient; - -/// -/// Represents a client for interacting with the Anthropic API. -/// -public interface IClient -{ - /// - /// Creates a chat message asynchronously. - /// - /// The chat message request to create. - /// A task that represents the asynchronous operation. The task result contains the chat response as an . - Task> CreateChatMessageAsync(ChatMessageRequest request); - - /// - /// Creates a chat message asynchronously and streams the response. - /// - /// The chat message request to create. - /// An asynchronous enumerable that yields the chat response line by line. - IAsyncEnumerable CreateChatMessageAsync(StreamChatMessageRequest request); -} - -/// -public class Client : IClient -{ - private const string BaseUrl = "https://api.anthropic.com/v1/"; - private const string ApiKeyHeader = "x-api-key"; - private const string MessagesEndpoint = "messages"; - private const string JsonContentType = "application/json"; - private const string RequestIdHeader = "request-id"; - private readonly Dictionary _defaultHeaders = new() - { - { "anthropic-version", "2023-06-01" }, - }; - private readonly HttpClient _httpClient; - - /// - /// Initializes a new instance of the class. - /// - /// The API key to use for the client. - /// The HTTP client to use for the client. - /// Thrown when the API key or HTTP client is null. - /// A new instance of the class. - public Client(string apiKey, HttpClient httpClient) - { - ArgumentValidator.ThrowIfNull(apiKey, nameof(apiKey)); - ArgumentValidator.ThrowIfNull(httpClient, nameof(httpClient)); - - _httpClient = httpClient; - _httpClient.BaseAddress = new Uri(BaseUrl); - _httpClient.DefaultRequestHeaders.Add(ApiKeyHeader, apiKey); - _httpClient.DefaultRequestHeaders - .Accept - .Add(new MediaTypeWithQualityHeaderValue(JsonContentType)); - - foreach (var pair in _defaultHeaders) - { - _httpClient.DefaultRequestHeaders.Add(pair.Key, pair.Value); - } - } - - /// - public async Task> CreateChatMessageAsync(ChatMessageRequest request) - { - var response = await SendRequestAsync(request); - var requestId = GetRequestId(response); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode is false) - { - var error = Deserialize(responseContent) ?? new AnthropicError(); - return AnthropicResult.Failure(error, requestId); - } - - var chatResponse = Deserialize(responseContent) ?? new ChatResponse(); - return AnthropicResult.Success(chatResponse, requestId); - } - - /// - public async IAsyncEnumerable CreateChatMessageAsync(StreamChatMessageRequest request) - { - var response = await SendRequestAsync(request); - var requestId = GetRequestId(response); - - using var responseContent = await response.Content.ReadAsStreamAsync(); - using var streamReader = new StreamReader(responseContent); - - var currentEvent = new AnthropicEvent(); - - // TODO: I'd like to emit custom events unique to this client - // - "content_block_complete" - // - provides the complete content block that was streamed - // - useful for streaming in text, but then handling tool calls with their entire input - - // will need to keep track of current content so that we can build it up as relevant - // deltas are streamed in - - // will want to capture the content block start event - // then continue to build this up until we get to the content block stop event - // at that point we should have the complete block and we should be able to yield - // return the complete block and reset the current content block - - // event: content_block_start - // data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01T1x1fJ34qAmk2tNTrN7Up6","name":"get_weather","input":{}}} - - - do - { - var line = await streamReader.ReadLineAsync(); - - if (line is null) - { - break; - } - - if (line.StartsWith("event:")) - { - var eventType = line.Substring("event:".Length).Trim(); - currentEvent = currentEvent with { Type = eventType }; - } - - if (line.StartsWith("data:")) - { - var eventData = line.Substring("data:".Length).Trim(); - var eventDataJson = JsonSerializer.Deserialize(eventData, JsonSerializationOptions.DefaultOptions); - currentEvent = currentEvent with { Data = eventDataJson! }; - } - - if (line == string.Empty) - { - yield return currentEvent; - currentEvent = new AnthropicEvent(); - } - } while (true); - } - - private async Task SendRequestAsync(MessageRequest request) - { - var requestContent = new StringContent(Serialize(request), Encoding.UTF8, JsonContentType); - return await _httpClient.PostAsync(MessagesEndpoint, requestContent); - } - - private string Serialize(T obj) => JsonSerializer.Serialize(obj, JsonSerializationOptions.DefaultOptions); - private T? Deserialize(string json) => JsonSerializer.Deserialize(json, JsonSerializationOptions.DefaultOptions); - private string GetRequestId(HttpResponseMessage response) => response.Headers.GetValues(RequestIdHeader).FirstOrDefault() ?? string.Empty; -} \ No newline at end of file diff --git a/src/AnthropicClient/Models/AnthropicEvent.cs b/src/AnthropicClient/Models/AnthropicEvent.cs index 91bab77..2205fd3 100644 --- a/src/AnthropicClient/Models/AnthropicEvent.cs +++ b/src/AnthropicClient/Models/AnthropicEvent.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; namespace AnthropicClient.Models; -public record AnthropicEvent +public class AnthropicEvent { public string Type { get; init; } = string.Empty; public EventData Data { get; init; } = default!; diff --git a/src/AnthropicClient/Models/AnthropicHeaders.cs b/src/AnthropicClient/Models/AnthropicHeaders.cs new file mode 100644 index 0000000..0eaac8f --- /dev/null +++ b/src/AnthropicClient/Models/AnthropicHeaders.cs @@ -0,0 +1,79 @@ +using System.Net.Http.Headers; + +namespace AnthropicClient.Models; + +/// +/// Represents headers included in Anthropic API responses. +/// +public class AnthropicHeaders +{ + private const string RequestIdHeaderKey = "request-id"; + private const string RateLimitRequestsLimitHeaderKey = "anthropic-ratelimit-requests-limit"; + private const string RateLimitRequestsRemainingHeaderKey = "anthropic-ratelimit-requests-remaining"; + private const string RateLimitRequestsResetHeaderKey = "anthropic-ratelimit-requests-reset"; + private const string RateLimitTokensLimitHeaderKey = "anthropic-ratelimit-tokens-limit"; + private const string RateLimitTokensRemainingHeaderKey = "anthropic-ratelimit-tokens-remaining"; + private const string RateLimitTokensResetHeaderKey = "anthropic-ratelimit-tokens-reset"; + private const string RetryAfterHeaderKey = "retry-after"; + + /// + /// Gets the request ID. + /// + public string RequestId { get; init; } = string.Empty; + + /// + /// Gets the rate limit requests limit. + /// + public int RateLimitRequestsLimit { get; init; } + + /// + /// Gets the rate limit requests remaining. + /// + public int RateLimitRequestsRemaining { get; init; } + + /// + /// Gets the time of the rate limit requests reset. + /// + public DateTimeOffset RateLimitRequestsReset { get; init; } + + /// + /// Gets the rate limit tokens limit. + /// + public int RateLimitTokensLimit { get; init; } + + /// + /// Gets the rate limit tokens remaining. + /// + public int RateLimitTokensRemaining { get; init; } + + /// + /// Gets the time of the rate limit tokens reset. + /// + public DateTimeOffset RateLimitTokensReset { get; init; } + + /// + /// Gets the retry after value. + /// + public int RetryAfter { get; init; } + + internal AnthropicHeaders() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP response headers. + /// A new instance of the class. + public AnthropicHeaders(HttpResponseHeaders headers) + { + RequestId = headers.GetValues(RequestIdHeaderKey).FirstOrDefault() ?? string.Empty; + RateLimitRequestsLimit = int.Parse(headers.GetValues(RateLimitRequestsLimitHeaderKey).FirstOrDefault() ?? "0"); + RateLimitRequestsRemaining = int.Parse(headers.GetValues(RateLimitRequestsRemainingHeaderKey).FirstOrDefault() ?? "0"); + RateLimitRequestsReset = DateTimeOffset.Parse(headers.GetValues(RateLimitRequestsResetHeaderKey).FirstOrDefault() ?? DateTimeOffset.MinValue.ToString()); + RateLimitTokensLimit = int.Parse(headers.GetValues(RateLimitTokensLimitHeaderKey).FirstOrDefault() ?? "0"); + RateLimitTokensRemaining = int.Parse(headers.GetValues(RateLimitTokensRemainingHeaderKey).FirstOrDefault() ?? "0"); + RateLimitTokensReset = DateTimeOffset.Parse(headers.GetValues(RateLimitTokensResetHeaderKey).FirstOrDefault() ?? DateTimeOffset.MinValue.ToString()); + RetryAfter = int.Parse(headers.GetValues(RetryAfterHeaderKey).FirstOrDefault() ?? "0"); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/AnthropicResult.cs b/src/AnthropicClient/Models/AnthropicResult.cs index 1fdc4e1..19c27a9 100644 --- a/src/AnthropicClient/Models/AnthropicResult.cs +++ b/src/AnthropicClient/Models/AnthropicResult.cs @@ -24,7 +24,7 @@ public class AnthropicResult /// /// The request ID of the operation. /// - public string RequestId { get; } + public AnthropicHeaders Headers { get; } /// /// Initializes a new instance of the class. @@ -32,35 +32,35 @@ public class AnthropicResult /// The value of the result. /// The error of the result. /// Indicates whether the operation was successful. - /// The request ID of the operation. + /// The Anthropic headers for the result. /// A new instance of the class. - protected AnthropicResult(T value, AnthropicError error, bool isSuccess, string requestId) + protected AnthropicResult(T value, AnthropicError error, bool isSuccess, AnthropicHeaders headers) { Value = value; Error = error; IsSuccess = isSuccess; - RequestId = requestId; + Headers = headers; } /// /// Creates a successful result. /// /// The value of the result. - /// The request ID of the operation. + /// The Anthropic headers for the result. /// A new instance of the class. - public static AnthropicResult Success(T value, string requestId) + public static AnthropicResult Success(T value, AnthropicHeaders headers) { - return new AnthropicResult(value, null!, true, requestId); + return new AnthropicResult(value, null!, true, headers); } /// /// Creates a failed result. /// /// The error of the result. - /// The request ID of the operation. + /// The Anthropic headers for the result. /// A new instance of the class. - public static AnthropicResult Failure(AnthropicError error, string requestId) + public static AnthropicResult Failure(AnthropicError error, AnthropicHeaders headers) { - return new AnthropicResult(default!, error, false, requestId); + return new AnthropicResult(default!, error, false, headers); } } diff --git a/src/AnthropicClient/Models/ContentStartEventData.cs b/src/AnthropicClient/Models/ContentStartEventData.cs index 1edfa2e..ed2c9cf 100644 --- a/src/AnthropicClient/Models/ContentStartEventData.cs +++ b/src/AnthropicClient/Models/ContentStartEventData.cs @@ -7,7 +7,7 @@ public class ContentStartEventData : EventData public int Index { get; init; } [JsonPropertyName("content_block")] - public Content ContentBlock { get; init; } + public Content ContentBlock { get; init; } = default!; [JsonConstructor] internal ContentStartEventData() : base(EventType.ContentBlockStart) diff --git a/src/AnthropicClient/Models/EventType.cs b/src/AnthropicClient/Models/EventType.cs index a652168..48c652f 100644 --- a/src/AnthropicClient/Models/EventType.cs +++ b/src/AnthropicClient/Models/EventType.cs @@ -7,6 +7,7 @@ public static class EventType public const string MessageStart = "message_start"; public const string MessageDelta = "message_delta"; public const string MessageStop = "message_stop"; + public const string MessageComplete = "message_complete"; public const string ContentBlockStart = "content_block_start"; public const string ContentBlockDelta = "content_block_delta"; public const string ContentBlockStop = "content_block_stop"; diff --git a/src/AnthropicClient/Models/MessageCompleteEventData.cs b/src/AnthropicClient/Models/MessageCompleteEventData.cs new file mode 100644 index 0000000..85218ad --- /dev/null +++ b/src/AnthropicClient/Models/MessageCompleteEventData.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +public class MessageCompleteEventData : EventData +{ + public AnthropicHeaders Headers { get; init; } = new(); + public ChatResponse Message { get; init; } = new(); + + public MessageCompleteEventData(ChatResponse message, AnthropicHeaders headers) : base(EventType.MessageComplete) + { + Message = message; + Headers = headers; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageDeltaEventData.cs b/src/AnthropicClient/Models/MessageDeltaEventData.cs index 240e307..b150d2e 100644 --- a/src/AnthropicClient/Models/MessageDeltaEventData.cs +++ b/src/AnthropicClient/Models/MessageDeltaEventData.cs @@ -4,8 +4,8 @@ namespace AnthropicClient.Models; public class MessageDeltaEventData : EventData { - public MessageDelta Delta { get; init; } - public ChatUsage Usage { get; init; } + public MessageDelta Delta { get; init; } = new(); + public ChatUsage Usage { get; init; } = new(); [JsonConstructor] internal MessageDeltaEventData() : base(EventType.MessageDelta) diff --git a/src/AnthropicClient/Models/MessageStartEventData.cs b/src/AnthropicClient/Models/MessageStartEventData.cs index 16b4451..ccf5c18 100644 --- a/src/AnthropicClient/Models/MessageStartEventData.cs +++ b/src/AnthropicClient/Models/MessageStartEventData.cs @@ -4,14 +4,14 @@ namespace AnthropicClient.Models; public class MessageStartEventData : EventData { - public ChatMessage Message { get; init; } = new(); + public ChatResponse Message { get; init; } = new(); [JsonConstructor] internal MessageStartEventData() : base(EventType.MessageStart) { } - public MessageStartEventData(ChatMessage message) : base(EventType.MessageStart) + public MessageStartEventData(ChatResponse message) : base(EventType.MessageStart) { Message = message; } diff --git a/tests/AnthropicClient.Tests/EndToEnd/ClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs similarity index 60% rename from tests/AnthropicClient.Tests/EndToEnd/ClientTests.cs rename to tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index aee03d6..fe5560c 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/ClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -6,7 +6,7 @@ public class ClientTests( ) : EndToEndTest(httpClientFixture, configFixture) { [Fact] - public async Task CreateChatMessage_WhenCalled_ShouldReturnChatResponse() + public async Task CreateChatMessage_WhenCalled_ItShouldReturnChatResponse() { var request = new ChatMessageRequest( model: AnthropicModels.Claude3Haiku, @@ -20,7 +20,7 @@ public class ClientTests( } [Fact] - public async Task CreateChatMessage_WhenCalledWithStreamRequest_IteratesOverChatResponse() + public async Task CreateChatMessage_WhenCalledWithStreamRequest_ItShouldReturnEvents() { var request = new StreamChatMessageRequest( model: AnthropicModels.Claude3Haiku, @@ -38,4 +38,24 @@ public class ClientTests( events.Should().NotBeEmpty(); } + + [Fact] + public async Task CreateChatMessage_WhenCalledWithStreamRequest_ItShouldYieldAMessageCompleteEvent() + { + var request = new StreamChatMessageRequest( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ); + + var response = _client.CreateChatMessageAsync(request); + + await foreach (var e in response) + { + if (e.Data is MessageCompleteEventData messageCompleteData) + { + messageCompleteData.Message.Should().NotBeNull(); + break; + } + } + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs b/tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs index c808412..b4cc22d 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs @@ -5,5 +5,5 @@ public class EndToEndTest( ConfigurationFixture configFixture ) : IClassFixture, IClassFixture { - protected readonly Client _client = new(configFixture.AnthropicApiKey, httpClientFixture.HttpClient); + protected readonly AnthropicApiClient _client = new(configFixture.AnthropicApiKey, httpClientFixture.HttpClient); } \ No newline at end of file