From 4c2cee643a0d7d88a25a7097a0f0f333b0d693f6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 27 Jun 2024 23:26:53 -0500 Subject: [PATCH] feat: create chat message --- src/AnthropicClient/Json/ContentConverter.cs | 29 + src/AnthropicClient/Json/ErrorConverter.cs | 33 + .../Json/JsonSerializationOptions.cs | 20 + .../Json/ToolChoiceConverter.cs | 28 + src/AnthropicClient/Models/AnthropicError.cs | 34 + src/AnthropicClient/Models/AnthropicModels.cs | 29 + src/AnthropicClient/Models/AnthropicResult.cs | 66 ++ src/AnthropicClient/Models/AnyToolChoice.cs | 13 + src/AnthropicClient/Models/ApiError.cs | 24 + .../Models/AuthenticationError.cs | 24 + src/AnthropicClient/Models/AutoToolChoice.cs | 13 + src/AnthropicClient/Models/ChatMessage.cs | 48 ++ .../Models/ChatMessageRequest.cs | 139 ++++ src/AnthropicClient/Models/ChatResponse.cs | 51 ++ src/AnthropicClient/Models/ChatUsage.cs | 21 + src/AnthropicClient/Models/Client.cs | 83 +++ src/AnthropicClient/Models/Content.cs | 29 + src/AnthropicClient/Models/ContentType.cs | 27 + src/AnthropicClient/Models/Error.cs | 27 + src/AnthropicClient/Models/ErrorType.cs | 42 ++ src/AnthropicClient/Models/ImageContent.cs | 36 + src/AnthropicClient/Models/ImageSource.cs | 50 ++ src/AnthropicClient/Models/ImageType.cs | 29 + src/AnthropicClient/Models/InputProperty.cs | 42 ++ src/AnthropicClient/Models/InputSchema.cs | 53 ++ .../Models/InvalidRequestError.cs | 24 + src/AnthropicClient/Models/IsExternalInit.cs | 7 + src/AnthropicClient/Models/MessageRequest.cs | 22 + src/AnthropicClient/Models/MessageRole.cs | 19 + src/AnthropicClient/Models/NotFoundError.cs | 24 + src/AnthropicClient/Models/OverloadedError.cs | 24 + src/AnthropicClient/Models/PermissionError.cs | 24 + src/AnthropicClient/Models/RateLimitError.cs | 24 + .../Models/SpecificToolChoice.cs | 34 + src/AnthropicClient/Models/StopReasonType.cs | 27 + src/AnthropicClient/Models/TextContent.cs | 34 + src/AnthropicClient/Models/Tool.cs | 51 ++ src/AnthropicClient/Models/ToolChoice.cs | 22 + src/AnthropicClient/Models/ToolChoiceType.cs | 24 + .../Models/ToolResultContent.cs | 41 ++ src/AnthropicClient/Models/ToolUseContent.cs | 30 + .../Utils/ArgumentValidator.cs | 23 + .../EndToEnd/ClientTests.cs | 21 + .../EndToEnd/ConfigurationFixture.cs | 23 + .../EndToEnd/EndToEndTest.cs | 9 + .../EndToEnd/HttpClientFixture.cs | 11 + .../Unit/Models/AnthropicErrorTests.cs | 168 +++++ .../Unit/Models/AnthropicModelsTests.cs | 67 ++ .../Unit/Models/AnthropicResultTests.cs | 30 + .../Unit/Models/AnyToolChoiceTests.cs | 38 + .../Unit/Models/AuthenticationErrorTests.cs | 40 ++ .../Unit/Models/AutoToolChoiceTests.cs | 38 + .../Unit/Models/ChatMessageRequestTests.cs | 653 ++++++++++++++++++ .../Unit/Models/ChatMessageTests.cs | 78 +++ .../Unit/Models/ChatResponseTests.cs | 120 ++++ .../Unit/Models/ChatUsageTests.cs | 47 ++ .../Unit/Models/ContentTypeTests.cs | 44 ++ .../Unit/Models/ImageContentTests.cs | 74 ++ .../Unit/Models/ImageTypeTests.cs | 57 ++ .../Unit/Models/InputPropertyTests.cs | 60 ++ .../Unit/Models/InputSchemaTests.cs | 116 ++++ .../Unit/Models/InvalidRequestErrorTests.cs | 47 ++ .../Unit/Models/MessageRoleTests.cs | 35 + .../Unit/Models/NotFoundErrorTests.cs | 40 ++ .../Unit/Models/OverloadedErrorTests.cs | 40 ++ .../Unit/Models/PermissionErrorTests.cs | 40 ++ .../Unit/Models/RateLimitErrorTests.cs | 40 ++ .../Unit/Models/SpecificToolChoiceTests.cs | 51 ++ .../Unit/Models/StopReasonTypeTests.cs | 44 ++ .../Unit/Models/TextContentTests.cs | 44 ++ .../Unit/Models/ToolChoiceTypeTests.cs | 46 ++ .../Unit/Models/ToolResultContentTests.cs | 79 +++ .../Unit/Models/ToolTests.cs | 88 +++ .../Unit/Models/ToolUseContentTests.cs | 83 +++ .../Unit/SerializationTest.cs | 12 + .../Unit/Utils/ArgumentValidatorTests.cs | 24 + tests/AnthropicClient.Tests/Usings.cs | 9 + 77 files changed, 3860 insertions(+) create mode 100644 src/AnthropicClient/Json/ContentConverter.cs create mode 100644 src/AnthropicClient/Json/ErrorConverter.cs create mode 100644 src/AnthropicClient/Json/JsonSerializationOptions.cs create mode 100644 src/AnthropicClient/Json/ToolChoiceConverter.cs create mode 100644 src/AnthropicClient/Models/AnthropicError.cs create mode 100644 src/AnthropicClient/Models/AnthropicModels.cs create mode 100644 src/AnthropicClient/Models/AnthropicResult.cs create mode 100644 src/AnthropicClient/Models/AnyToolChoice.cs create mode 100644 src/AnthropicClient/Models/ApiError.cs create mode 100644 src/AnthropicClient/Models/AuthenticationError.cs create mode 100644 src/AnthropicClient/Models/AutoToolChoice.cs create mode 100644 src/AnthropicClient/Models/ChatMessage.cs create mode 100644 src/AnthropicClient/Models/ChatMessageRequest.cs create mode 100644 src/AnthropicClient/Models/ChatResponse.cs create mode 100644 src/AnthropicClient/Models/ChatUsage.cs create mode 100644 src/AnthropicClient/Models/Client.cs create mode 100644 src/AnthropicClient/Models/Content.cs create mode 100644 src/AnthropicClient/Models/ContentType.cs create mode 100644 src/AnthropicClient/Models/Error.cs create mode 100644 src/AnthropicClient/Models/ErrorType.cs create mode 100644 src/AnthropicClient/Models/ImageContent.cs create mode 100644 src/AnthropicClient/Models/ImageSource.cs create mode 100644 src/AnthropicClient/Models/ImageType.cs create mode 100644 src/AnthropicClient/Models/InputProperty.cs create mode 100644 src/AnthropicClient/Models/InputSchema.cs create mode 100644 src/AnthropicClient/Models/InvalidRequestError.cs create mode 100644 src/AnthropicClient/Models/IsExternalInit.cs create mode 100644 src/AnthropicClient/Models/MessageRequest.cs create mode 100644 src/AnthropicClient/Models/MessageRole.cs create mode 100644 src/AnthropicClient/Models/NotFoundError.cs create mode 100644 src/AnthropicClient/Models/OverloadedError.cs create mode 100644 src/AnthropicClient/Models/PermissionError.cs create mode 100644 src/AnthropicClient/Models/RateLimitError.cs create mode 100644 src/AnthropicClient/Models/SpecificToolChoice.cs create mode 100644 src/AnthropicClient/Models/StopReasonType.cs create mode 100644 src/AnthropicClient/Models/TextContent.cs create mode 100644 src/AnthropicClient/Models/Tool.cs create mode 100644 src/AnthropicClient/Models/ToolChoice.cs create mode 100644 src/AnthropicClient/Models/ToolChoiceType.cs create mode 100644 src/AnthropicClient/Models/ToolResultContent.cs create mode 100644 src/AnthropicClient/Models/ToolUseContent.cs create mode 100644 src/AnthropicClient/Utils/ArgumentValidator.cs create mode 100644 tests/AnthropicClient.Tests/EndToEnd/ClientTests.cs create mode 100644 tests/AnthropicClient.Tests/EndToEnd/ConfigurationFixture.cs create mode 100644 tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs create mode 100644 tests/AnthropicClient.Tests/EndToEnd/HttpClientFixture.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AnthropicErrorTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AnthropicResultTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AnyToolChoiceTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AuthenticationErrorTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/AutoToolChoiceTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ChatMessageRequestTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ChatMessageTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ChatResponseTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ChatUsageTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ContentTypeTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ImageTypeTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/InputPropertyTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/InputSchemaTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/InvalidRequestErrorTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageRoleTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/NotFoundErrorTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/OverloadedErrorTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/PermissionErrorTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/RateLimitErrorTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/SpecificToolChoiceTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/StopReasonTypeTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/TextContentTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ToolChoiceTypeTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ToolResultContentTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ToolTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ToolUseContentTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/SerializationTest.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Utils/ArgumentValidatorTests.cs create mode 100644 tests/AnthropicClient.Tests/Usings.cs diff --git a/src/AnthropicClient/Json/ContentConverter.cs b/src/AnthropicClient/Json/ContentConverter.cs new file mode 100644 index 0000000..e0183d4 --- /dev/null +++ b/src/AnthropicClient/Json/ContentConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AnthropicClient.Models; + +namespace AnthropicClient.Json; + +class ContentConverter : JsonConverter +{ + public override Content Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDocument = JsonDocument.ParseValue(ref reader); + var root = jsonDocument.RootElement; + var type = root.GetProperty("type").GetString(); + return type switch + { + ContentType.Text => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ContentType.Image => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ContentType.ToolUse => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ContentType.ToolResult => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown content type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, Content value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Json/ErrorConverter.cs b/src/AnthropicClient/Json/ErrorConverter.cs new file mode 100644 index 0000000..e2be34d --- /dev/null +++ b/src/AnthropicClient/Json/ErrorConverter.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AnthropicClient.Models; + +namespace AnthropicClient.Json; + +class ErrorConverter : JsonConverter +{ + public override Error Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDocument = JsonDocument.ParseValue(ref reader); + var root = jsonDocument.RootElement; + var type = root.GetProperty("type").GetString(); + + return type switch + { + ErrorType.InvalidRequestError => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ErrorType.AuthenticationError => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ErrorType.PermissionError => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ErrorType.NotFoundError => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ErrorType.RateLimitError => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ErrorType.ApiError => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ErrorType.OverloadedError => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown error type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, Error value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Json/JsonSerializationOptions.cs b/src/AnthropicClient/Json/JsonSerializationOptions.cs new file mode 100644 index 0000000..15214b0 --- /dev/null +++ b/src/AnthropicClient/Json/JsonSerializationOptions.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AnthropicClient.Json; + +static class JsonSerializationOptions +{ + public static JsonSerializerOptions DefaultOptions => new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + new ContentConverter(), + new ToolChoiceConverter(), + new ErrorConverter(), + }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} \ No newline at end of file diff --git a/src/AnthropicClient/Json/ToolChoiceConverter.cs b/src/AnthropicClient/Json/ToolChoiceConverter.cs new file mode 100644 index 0000000..3869b77 --- /dev/null +++ b/src/AnthropicClient/Json/ToolChoiceConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AnthropicClient.Models; + +namespace AnthropicClient.Json; + +class ToolChoiceConverter : JsonConverter +{ + public override ToolChoice Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDocument = JsonDocument.ParseValue(ref reader); + var root = jsonDocument.RootElement; + var type = root.GetProperty("type").GetString(); + return type switch + { + ToolChoiceType.Auto => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ToolChoiceType.Any => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ToolChoiceType.Tool => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown tool choice type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/AnthropicError.cs b/src/AnthropicClient/Models/AnthropicError.cs new file mode 100644 index 0000000..1efcf14 --- /dev/null +++ b/src/AnthropicClient/Models/AnthropicError.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents an error response from the Anthropic API. +/// +public class AnthropicError +{ + /// + /// The type of the error. + /// + public string Type { get; init; } = "error"; + + /// + /// The error object. + /// + public Error? Error { get; init; } = null; + + [JsonConstructor] + internal AnthropicError() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error as an object. + /// A new instance of the class. + public AnthropicError(Error error) + { + Error = error; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/AnthropicModels.cs b/src/AnthropicClient/Models/AnthropicModels.cs new file mode 100644 index 0000000..8fb437b --- /dev/null +++ b/src/AnthropicClient/Models/AnthropicModels.cs @@ -0,0 +1,29 @@ +namespace AnthropicClient.Models; + +/// +/// Provides constants for the Anthropic models. +/// +public static class AnthropicModels +{ + /// + /// The Claude-3 Opus model. + /// + public const string Claude3Opus = "claude-3-opus-20240229"; + + /// + /// The Claude-3 Sonnet model. + /// + public const string Claude3Sonnet = "claude-3-sonnet-20240229"; + + /// + /// The Claude-3.5 Sonnet model. + /// + public const string Claude35Sonnet = "claude-3-5-sonnet-20240620"; + + /// + /// The Claude-3 Haiku model. + /// + public const string Claude3Haiku = "claude-3-haiku-20240307"; + + internal static bool IsValidModel(string modelId) => modelId is Claude3Opus or Claude3Sonnet or Claude35Sonnet or Claude3Haiku; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/AnthropicResult.cs b/src/AnthropicClient/Models/AnthropicResult.cs new file mode 100644 index 0000000..1fdc4e1 --- /dev/null +++ b/src/AnthropicClient/Models/AnthropicResult.cs @@ -0,0 +1,66 @@ +using AnthropicClient.Models; + +/// +/// Represents the result of an Anthropic API operation. +/// +/// The type of the result value. +public class AnthropicResult +{ + /// + /// The value of the result. + /// + public T Value { get; } + + /// + /// The error of the result. + /// + public AnthropicError Error { get; } + + /// + /// Indicates whether the operation was successful. + /// + public bool IsSuccess { get; } + + /// + /// The request ID of the operation. + /// + public string RequestId { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of the result. + /// The error of the result. + /// Indicates whether the operation was successful. + /// The request ID of the operation. + /// A new instance of the class. + protected AnthropicResult(T value, AnthropicError error, bool isSuccess, string requestId) + { + Value = value; + Error = error; + IsSuccess = isSuccess; + RequestId = requestId; + } + + /// + /// Creates a successful result. + /// + /// The value of the result. + /// The request ID of the operation. + /// A new instance of the class. + public static AnthropicResult Success(T value, string requestId) + { + return new AnthropicResult(value, null!, true, requestId); + } + + /// + /// Creates a failed result. + /// + /// The error of the result. + /// The request ID of the operation. + /// A new instance of the class. + public static AnthropicResult Failure(AnthropicError error, string requestId) + { + return new AnthropicResult(default!, error, false, requestId); + } +} diff --git a/src/AnthropicClient/Models/AnyToolChoice.cs b/src/AnthropicClient/Models/AnyToolChoice.cs new file mode 100644 index 0000000..57bf720 --- /dev/null +++ b/src/AnthropicClient/Models/AnyToolChoice.cs @@ -0,0 +1,13 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the any tool choice mode. +/// +public class AnyToolChoice : ToolChoice +{ + /// + /// Initializes a new instance of the class. + /// + /// A new instance of the class. + public AnyToolChoice() : base(ToolChoiceType.Any) { } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ApiError.cs b/src/AnthropicClient/Models/ApiError.cs new file mode 100644 index 0000000..a012ca5 --- /dev/null +++ b/src/AnthropicClient/Models/ApiError.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents an api_error response from the Anthropic API. +/// +public class ApiError : Error +{ + [JsonConstructor] + internal ApiError() : base(ErrorType.ApiError) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// A new instance of the class. + public ApiError(string message) : base(ErrorType.ApiError) + { + Message = message; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/AuthenticationError.cs b/src/AnthropicClient/Models/AuthenticationError.cs new file mode 100644 index 0000000..222e127 --- /dev/null +++ b/src/AnthropicClient/Models/AuthenticationError.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents an authentication_error response from the Anthropic API. +/// +public class AuthenticationError : Error +{ + [JsonConstructor] + internal AuthenticationError() : base(ErrorType.AuthenticationError) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// A new instance of the class. + public AuthenticationError(string message) : base(ErrorType.AuthenticationError) + { + Message = message; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/AutoToolChoice.cs b/src/AnthropicClient/Models/AutoToolChoice.cs new file mode 100644 index 0000000..96cbfa3 --- /dev/null +++ b/src/AnthropicClient/Models/AutoToolChoice.cs @@ -0,0 +1,13 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the auto tool choice mode. +/// +public class AutoToolChoice : ToolChoice +{ + /// + /// Initializes a new instance of the class. + /// + /// A new instance of the class. + public AutoToolChoice() : base(ToolChoiceType.Auto) { } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ChatMessage.cs b/src/AnthropicClient/Models/ChatMessage.cs new file mode 100644 index 0000000..e3d09f2 --- /dev/null +++ b/src/AnthropicClient/Models/ChatMessage.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a chat message. +/// +public class ChatMessage +{ + /// + /// Gets the role of the message. + /// + public string Role { get; init; } = string.Empty; + + /// + /// Gets the contents of the message. + /// + public List Content { get; init; } = []; + + [JsonConstructor] + internal ChatMessage() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The role of the message. + /// The contents of the message. + /// Thrown when the role is invalid. + /// Thrown when the role or content is null. + /// A new instance of the class. + public ChatMessage(string role, List content) + { + ArgumentValidator.ThrowIfNull(role, nameof(role)); + ArgumentValidator.ThrowIfNull(content, nameof(content)); + + if (MessageRole.IsValidRole(role) is false) + { + throw new ArgumentException($"Invalid role: {role}"); + } + + Role = role; + Content = content; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ChatMessageRequest.cs b/src/AnthropicClient/Models/ChatMessageRequest.cs new file mode 100644 index 0000000..6a54b95 --- /dev/null +++ b/src/AnthropicClient/Models/ChatMessageRequest.cs @@ -0,0 +1,139 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a chat message request. +/// +public class ChatMessageRequest : MessageRequest +{ + /// + /// Gets the model ID to use for the request. + /// + public string Model { get; init; } = string.Empty; + + /// + /// Gets the system ID to use for the request. + /// + public string? System { get; init; } = null; + + /// + /// Gets the messages to send to the model. + /// + public List Messages { get; init; } = []; + + /// + /// Gets the maximum number of tokens to generate. + /// + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; init; } = 1024; + + /// + /// Gets the metadata to include with the request. + /// + public Dictionary? Metadata { get; init; } = null; + + /// + /// Gets the prompt stop sequences. + /// + [JsonPropertyName("stop_sequences")] + public List StopSequences { get; init; } = []; + + /// + /// Gets the temperature to use for the request. + /// + public decimal Temperature { get; init; } = 0.0m; + + /// + /// Gets the top-K value to use for the request. + /// + public int? TopK { get; init; } = null; + + /// + /// Gets the top-P value to use for the request. + /// + public decimal? TopP { get; init; } = null; + + /// + /// 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; + + [JsonConstructor] + internal ChatMessageRequest() : base(false) { } + + /// + /// Initializes a new instance of the class. + /// + /// The model ID to use for the request. + /// The messages to send to the model. + /// The maximum number of tokens to generate. + /// The system ID to use for the request. + /// The metadata to include with the request. + /// The temperature to use for the request. + /// The top-K value to use for the request. + /// The top-P value to use for the request. + /// The tool choice mode to use for the request. + /// The tools 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. + /// Thrown when the temperature is less than zero or greater than one. + /// A new instance of the class. + public ChatMessageRequest( + string model, + List messages, + int maxTokens = 1024, + string? system = null, + Dictionary? metadata = null, + decimal temperature = 0.0m, + int? topK = null, + decimal? topP = null, + ToolChoice? toolChoice = null, + List? tools = null + ) : base(false) + { + 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"); + } + + if (maxTokens < 1) + { + throw new ArgumentException($"Invalid max tokens: {maxTokens}"); + } + + if (temperature < 0.0m || temperature > 1.0m) + { + throw new ArgumentException($"Invalid temperature: {temperature}"); + } + + Model = model; + Messages = messages; + MaxTokens = maxTokens; + System = system; + Metadata = metadata; + Temperature = temperature; + TopK = topK; + TopP = topP; + ToolChoice = toolChoice; + Tools = tools; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ChatResponse.cs b/src/AnthropicClient/Models/ChatResponse.cs new file mode 100644 index 0000000..b95e461 --- /dev/null +++ b/src/AnthropicClient/Models/ChatResponse.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a chat response. +/// +public class ChatResponse +{ + /// + /// Gets the ID of the chat response. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets the model used for the chat response. + /// + public string Model { get; set; } = string.Empty; + + /// + /// Gets the role of the chat response. + /// + public string Role { get; set; } = string.Empty; + + /// + /// Gets the stop reason of the chat response. + /// + [JsonPropertyName("stop_reason")] + public string StopReason { get; set; } = string.Empty; + + /// + /// Gets the stop sequence of the chat response. + /// + [JsonPropertyName("stop_sequence")] + public string StopSequence { get; set; } = string.Empty; + + /// + /// Gets the type of the chat response. + /// + public string Type { get; set; } = string.Empty; + + /// + /// Gets the usage of the chat response. + /// + public ChatUsage Usage { get; set; } = new(); + + /// + /// Gets the contents of the chat response. + /// + public List Content { get; set; } = []; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ChatUsage.cs b/src/AnthropicClient/Models/ChatUsage.cs new file mode 100644 index 0000000..73312ee --- /dev/null +++ b/src/AnthropicClient/Models/ChatUsage.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents the usage of a chat response. +/// +public class ChatUsage +{ + /// + /// Gets the number of input tokens used. + /// + [JsonPropertyName("input_tokens")] + public int InputTokens { get; set; } + + /// + /// Gets the number of output tokens used. + /// + [JsonPropertyName("output_tokens")] + public int OutputTokens { get; set; } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/Client.cs b/src/AnthropicClient/Models/Client.cs new file mode 100644 index 0000000..ba00ae2 --- /dev/null +++ b/src/AnthropicClient/Models/Client.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +using AnthropicClient.Json; +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// 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); +} + +/// +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 requestContent = new StringContent(Serialize(request), Encoding.UTF8, JsonContentType); + var response = await _httpClient.PostAsync(MessagesEndpoint, requestContent); + 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); + } + + 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/Content.cs b/src/AnthropicClient/Models/Content.cs new file mode 100644 index 0000000..7d4a7ee --- /dev/null +++ b/src/AnthropicClient/Models/Content.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents part of the content of a chat message. +/// +public abstract class Content +{ + /// + /// Gets the type of the content. + /// + public string Type { get; init; } = string.Empty; + + [JsonConstructor] + internal Content() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The type of the content. + /// A new instance of the class. + protected Content(string type) + { + Type = type; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ContentType.cs b/src/AnthropicClient/Models/ContentType.cs new file mode 100644 index 0000000..e34013c --- /dev/null +++ b/src/AnthropicClient/Models/ContentType.cs @@ -0,0 +1,27 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the content type. +/// +public static class ContentType +{ + /// + /// Represents the text content type. + /// + public const string Text = "text"; + + /// + /// Represents the image content type. + /// + public const string Image = "image"; + + /// + /// Represents the tool use content type. + /// + public const string ToolUse = "tool_use"; + + /// + /// Represents the tool result content type. + /// + public const string ToolResult = "tool_result"; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/Error.cs b/src/AnthropicClient/Models/Error.cs new file mode 100644 index 0000000..ac28133 --- /dev/null +++ b/src/AnthropicClient/Models/Error.cs @@ -0,0 +1,27 @@ +namespace AnthropicClient.Models; + +/// +/// Represents an error. +/// +public abstract class Error +{ + /// + /// Gets the type of the error. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Gets the message of the error. + /// + public string Message { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The type of the error. + /// A new instance of the class. + protected Error(string type) + { + Type = type; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ErrorType.cs b/src/AnthropicClient/Models/ErrorType.cs new file mode 100644 index 0000000..19f6220 --- /dev/null +++ b/src/AnthropicClient/Models/ErrorType.cs @@ -0,0 +1,42 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the error type. +/// +public class ErrorType +{ + /// + /// Represents the invalid_request error type. + /// + public const string InvalidRequestError = "invalid_request_error"; + + /// + /// Represents the authentication_error type. + /// + public const string AuthenticationError = "authentication_error"; + + /// + /// Represents the permission_error type. + /// + public const string PermissionError = "permission_error"; + + /// + /// Represents the not_found_error type. + /// + public const string NotFoundError = "not_found_error"; + + /// + /// Represents the rate_limit_error type. + /// + public const string RateLimitError = "rate_limit_error"; + + /// + /// Represents the api_error type. + /// + public const string ApiError = "api_error"; + + /// + /// Represents the overloaded_error type. + /// + public const string OverloadedError = "overloaded_error"; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ImageContent.cs b/src/AnthropicClient/Models/ImageContent.cs new file mode 100644 index 0000000..e129880 --- /dev/null +++ b/src/AnthropicClient/Models/ImageContent.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents image content that is part of a chat message. +/// +public class ImageContent : Content +{ + /// + /// Gets the source of the image. + /// + public ImageSource Source { get; init; } = new(); + + [JsonConstructor] + internal ImageContent() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The media type of the image. + /// The data of the image. + /// Thrown when the media type or data is null. + /// A new instance of the class. + public ImageContent(string mediaType, string data) : base(ContentType.Image) + { + ArgumentValidator.ThrowIfNull(mediaType, nameof(mediaType)); + ArgumentValidator.ThrowIfNull(data, nameof(data)); + + Source = new(mediaType, data); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ImageSource.cs b/src/AnthropicClient/Models/ImageSource.cs new file mode 100644 index 0000000..4eca86d --- /dev/null +++ b/src/AnthropicClient/Models/ImageSource.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents an image source. +/// +public class ImageSource +{ + /// + /// Gets the media type of the image. + /// + [JsonPropertyName("media_type")] + public string MediaType { get; init; } = string.Empty; + + /// + /// Gets the data of the image. + /// + public string Data { get; init; } = string.Empty; + + [JsonConstructor] + internal ImageSource() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The media type of the image. + /// The data of the image. + /// Thrown when the media type is invalid. + /// Thrown when the media type or data is null. + /// A new instance of the class. + public ImageSource(string mediaType, string data) + { + ArgumentValidator.ThrowIfNull(mediaType, nameof(mediaType)); + + if (ImageType.IsValidImageType(mediaType) is false) + { + throw new ArgumentException($"Invalid media type: {mediaType}"); + } + + ArgumentValidator.ThrowIfNull(data, nameof(data)); + + MediaType = mediaType; + Data = data; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ImageType.cs b/src/AnthropicClient/Models/ImageType.cs new file mode 100644 index 0000000..3fd5415 --- /dev/null +++ b/src/AnthropicClient/Models/ImageType.cs @@ -0,0 +1,29 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the image type. +/// +public static class ImageType +{ + /// + /// Represents the JPEG image type. + /// + public const string Jpg = "image/jpeg"; + + /// + /// Represents the PNG image type. + /// + public const string Png = "image/png"; + + /// + /// Represents the GIF image type. + /// + public const string Gif = "image/gif"; + + /// + /// Represents the WebP image type. + /// + public const string Webp = "image/webp"; + + internal static bool IsValidImageType(string imageType) => imageType is Jpg or Png or Gif or Webp; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/InputProperty.cs b/src/AnthropicClient/Models/InputProperty.cs new file mode 100644 index 0000000..d26b568 --- /dev/null +++ b/src/AnthropicClient/Models/InputProperty.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents an input property. +/// +public class InputProperty +{ + /// + /// Gets the type of the input property. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Gets the description of the input property. + /// + public string Description { get; init; } = string.Empty; + + [JsonConstructor] + internal InputProperty() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The type of the input property. + /// The description of the input property. + /// Thrown when the type or description is null. + /// A new instance of the class. + public InputProperty(string type, string description) + { + ArgumentValidator.ThrowIfNull(type, nameof(type)); + ArgumentValidator.ThrowIfNull(description, nameof(description)); + + Type = type; + Description = description; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/InputSchema.cs b/src/AnthropicClient/Models/InputSchema.cs new file mode 100644 index 0000000..467b00b --- /dev/null +++ b/src/AnthropicClient/Models/InputSchema.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents an input schema. +/// +public class InputSchema +{ + /// + /// Gets the type of the input schema. + /// + public string Type { get; init; } = "object"; + + /// + /// Gets the properties of the input schema. + /// + public Dictionary Properties { get; init; } = []; + + /// + /// Gets the required properties of the input schema. + /// + public List Required { get; init; } = []; + + [JsonConstructor] + internal InputSchema() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The properties of the input schema. + /// The required properties of the input schema. + /// Thrown when the properties or required is null. + /// Thrown when the required properties are not present in the properties dictionary. + /// A new instance of the class. + public InputSchema(Dictionary properties, List required) + { + ArgumentValidator.ThrowIfNull(properties, nameof(properties)); + ArgumentValidator.ThrowIfNull(required, nameof(required)); + + if (required.Any(r => properties.ContainsKey(r) is false)) + { + throw new ArgumentException("Required properties must be present in the properties dictionary."); + } + + Properties = properties; + Required = required; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/InvalidRequestError.cs b/src/AnthropicClient/Models/InvalidRequestError.cs new file mode 100644 index 0000000..a90f41a --- /dev/null +++ b/src/AnthropicClient/Models/InvalidRequestError.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents an invalid_request error. +/// +public class InvalidRequestError : Error +{ + [JsonConstructor] + internal InvalidRequestError() : base(ErrorType.InvalidRequestError) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message of the error. + /// A new instance of the class. + public InvalidRequestError(string message) : base(ErrorType.InvalidRequestError) + { + Message = message; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/IsExternalInit.cs b/src/AnthropicClient/Models/IsExternalInit.cs new file mode 100644 index 0000000..e87ab11 --- /dev/null +++ b/src/AnthropicClient/Models/IsExternalInit.cs @@ -0,0 +1,7 @@ +using System.ComponentModel; + +namespace System.Runtime.CompilerServices +{ + [EditorBrowsable(EditorBrowsableState.Never)] + class IsExternalInit { } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageRequest.cs b/src/AnthropicClient/Models/MessageRequest.cs new file mode 100644 index 0000000..a676c4e --- /dev/null +++ b/src/AnthropicClient/Models/MessageRequest.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a message request. +/// +public abstract class MessageRequest +{ + /// + /// Gets a value indicating whether the message should be streamed. + /// + public bool Stream { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// A value indicating whether the message should be streamed. + /// A new instance of the class. + public MessageRequest(bool stream = false) + { + Stream = stream; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/MessageRole.cs b/src/AnthropicClient/Models/MessageRole.cs new file mode 100644 index 0000000..45ad365 --- /dev/null +++ b/src/AnthropicClient/Models/MessageRole.cs @@ -0,0 +1,19 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the message role. +/// +public static class MessageRole +{ + /// + /// Represents the user role. + /// + public const string User = "user"; + + /// + /// Represents the assistant role. + /// + public const string Assistant = "assistant"; + + internal static bool IsValidRole(string role) => role is User or Assistant; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/NotFoundError.cs b/src/AnthropicClient/Models/NotFoundError.cs new file mode 100644 index 0000000..4933003 --- /dev/null +++ b/src/AnthropicClient/Models/NotFoundError.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a not_found error. +/// +public class NotFoundError : Error +{ + [JsonConstructor] + internal NotFoundError() : base(ErrorType.NotFoundError) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message of the error. + /// A new instance of the class. + public NotFoundError(string message) : base(ErrorType.NotFoundError) + { + Message = message; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/OverloadedError.cs b/src/AnthropicClient/Models/OverloadedError.cs new file mode 100644 index 0000000..4a842c8 --- /dev/null +++ b/src/AnthropicClient/Models/OverloadedError.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents an overloaded error. +/// +public class OverloadedError : Error +{ + [JsonConstructor] + internal OverloadedError() : base(ErrorType.OverloadedError) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message of the error. + /// A new instance of the class. + public OverloadedError(string message) : base(ErrorType.OverloadedError) + { + Message = message; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/PermissionError.cs b/src/AnthropicClient/Models/PermissionError.cs new file mode 100644 index 0000000..5eedac6 --- /dev/null +++ b/src/AnthropicClient/Models/PermissionError.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a permission error. +/// +public class PermissionError : Error +{ + [JsonConstructor] + internal PermissionError() : base(ErrorType.PermissionError) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message of the error. + /// A new instance of the class. + public PermissionError(string message) : base(ErrorType.PermissionError) + { + Message = message; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/RateLimitError.cs b/src/AnthropicClient/Models/RateLimitError.cs new file mode 100644 index 0000000..2ba34d3 --- /dev/null +++ b/src/AnthropicClient/Models/RateLimitError.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a rate_limit error. +/// +public class RateLimitError : Error +{ + [JsonConstructor] + internal RateLimitError() : base(ErrorType.RateLimitError) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message of the error. + /// A new instance of the class. + public RateLimitError(string message) : base(ErrorType.RateLimitError) + { + Message = message; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/SpecificToolChoice.cs b/src/AnthropicClient/Models/SpecificToolChoice.cs new file mode 100644 index 0000000..d85ba00 --- /dev/null +++ b/src/AnthropicClient/Models/SpecificToolChoice.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents the specific tool choice mode. +/// +public class SpecificToolChoice : ToolChoice +{ + /// + /// Gets the name of the tool. + /// + public string Name { get; init; } = string.Empty; + + [JsonConstructor] + internal SpecificToolChoice() : base(ToolChoiceType.Tool) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the tool. + /// Thrown when the name is null. + /// A new instance of the class. + public SpecificToolChoice(string name) : base(ToolChoiceType.Tool) + { + ArgumentValidator.ThrowIfNull(name, nameof(name)); + + Name = name; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/StopReasonType.cs b/src/AnthropicClient/Models/StopReasonType.cs new file mode 100644 index 0000000..47ff11b --- /dev/null +++ b/src/AnthropicClient/Models/StopReasonType.cs @@ -0,0 +1,27 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the stop reason type. +/// +public static class StopReasonType +{ + /// + /// Represents the end_turn stop reason. + /// + public const string EndTurn = "end_turn"; + + /// + /// Represents the max_tokens stop reason. + /// + public const string MaxTokens = "max_tokens"; + + /// + /// Represents the stop_sequence stop reason. + /// + public const string StopSequence = "stop_sequence"; + + /// + /// Represents the tool_use stop reason. + /// + public const string ToolUse = "tool_use"; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextContent.cs b/src/AnthropicClient/Models/TextContent.cs new file mode 100644 index 0000000..1cf44b3 --- /dev/null +++ b/src/AnthropicClient/Models/TextContent.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents text content that is part of a chat message. +/// +public class TextContent : Content +{ + /// + /// Gets the text of the content. + /// + public string Text { get; init; } = string.Empty; + + [JsonConstructor] + internal TextContent() : base(ContentType.Text) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The text of the content. + /// Thrown when the text is null. + /// A new instance of the class. + public TextContent(string text) : base(ContentType.Text) + { + ArgumentValidator.ThrowIfNull(text, nameof(text)); + + Text = text; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/Tool.cs b/src/AnthropicClient/Models/Tool.cs new file mode 100644 index 0000000..ea4be30 --- /dev/null +++ b/src/AnthropicClient/Models/Tool.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a tool that can be used in the chat. +/// +public class Tool +{ + /// + /// Gets the name of the tool. + /// + public string Name { get; init; } = string.Empty; + + /// + /// Gets the description of the tool. + /// + public string Description { get; init; } = string.Empty; + + /// + /// Gets the input schema of the tool. + /// + [JsonPropertyName("input_schema")] + public InputSchema InputSchema { get; init; } = new(); + + [JsonConstructor] + internal Tool() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the tool. + /// The description of the tool. + /// The input schema of the tool. + /// Thrown when the name, description, or input schema is null. + /// A new instance of the class. + public Tool(string name, string description, InputSchema inputSchema) + { + ArgumentValidator.ThrowIfNull(name, nameof(name)); + ArgumentValidator.ThrowIfNull(description, nameof(description)); + ArgumentValidator.ThrowIfNull(inputSchema, nameof(inputSchema)); + + Name = name; + Description = description; + InputSchema = inputSchema; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ToolChoice.cs b/src/AnthropicClient/Models/ToolChoice.cs new file mode 100644 index 0000000..4238ae6 --- /dev/null +++ b/src/AnthropicClient/Models/ToolChoice.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a tool choice mode. +/// +public abstract class ToolChoice +{ + /// + /// Gets the type of the tool choice. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The type of the tool choice. + /// A new instance of the class. + protected ToolChoice(string type) + { + Type = type; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ToolChoiceType.cs b/src/AnthropicClient/Models/ToolChoiceType.cs new file mode 100644 index 0000000..79d7c86 --- /dev/null +++ b/src/AnthropicClient/Models/ToolChoiceType.cs @@ -0,0 +1,24 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the tool choice type. +/// +public class ToolChoiceType +{ + /// + /// Represents the auto tool choice type. + /// + public const string Auto = "auto"; + + /// + /// Represents the any tool choice type. + /// + public const string Any = "any"; + + /// + /// Represents the specific tool choice type. + /// + public const string Tool = "tool"; + + internal static bool IsValidType(string type) => type is Auto or Any or Tool; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ToolResultContent.cs b/src/AnthropicClient/Models/ToolResultContent.cs new file mode 100644 index 0000000..f8b8f82 --- /dev/null +++ b/src/AnthropicClient/Models/ToolResultContent.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents tool result content that is part of a chat message. +/// +public class ToolResultContent : Content +{ + /// + /// Gets the tool use ID of the content. + /// + [JsonPropertyName("tool_use_id")] + public string ToolUseId { get; set; } = string.Empty; + + /// + /// Gets the content of the tool result. + /// + public string Content { get; set; } = string.Empty; + + [JsonConstructor] + internal ToolResultContent() : base(ContentType.ToolResult) { } + + /// + /// Initializes a new instance of the class. + /// + /// The tool use ID of the content. + /// The content of the tool result. + /// Thrown when the tool use ID or content is null. + /// A new instance of the class. + public ToolResultContent(string toolUseId, string content) : base(ContentType.ToolResult) + { + ArgumentValidator.ThrowIfNull(toolUseId, nameof(toolUseId)); + ArgumentValidator.ThrowIfNull(content, nameof(content)); + + ToolUseId = toolUseId; + Content = content; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ToolUseContent.cs b/src/AnthropicClient/Models/ToolUseContent.cs new file mode 100644 index 0000000..aef8668 --- /dev/null +++ b/src/AnthropicClient/Models/ToolUseContent.cs @@ -0,0 +1,30 @@ +namespace AnthropicClient.Models; + +/// +/// Represents tool use content that is part of a message. +/// +public class ToolUseContent : Content +{ + /// + /// Gets the ID of the tool use. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets the name of the tool. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets the input of the tool. + /// + public Dictionary Input { get; set; } = []; + + /// + /// Initializes a new instance of the class. + /// + /// A new instance of the class. + public ToolUseContent() : base(ContentType.ToolUse) + { + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Utils/ArgumentValidator.cs b/src/AnthropicClient/Utils/ArgumentValidator.cs new file mode 100644 index 0000000..fc494d9 --- /dev/null +++ b/src/AnthropicClient/Utils/ArgumentValidator.cs @@ -0,0 +1,23 @@ +namespace AnthropicClient.Utils; + +/// +/// Provides methods to validate arguments. +/// +public static class ArgumentValidator +{ + /// + /// Throws an if the value is null. + /// + /// The type of the value. + /// The value to check. + /// The name of the value. + /// Thrown when the value is null. + /// Nothing. + public static void ThrowIfNull(T? value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/ClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/ClientTests.cs new file mode 100644 index 0000000..d51f0ba --- /dev/null +++ b/tests/AnthropicClient.Tests/EndToEnd/ClientTests.cs @@ -0,0 +1,21 @@ +namespace AnthropicClient.Tests.EndToEnd; + +public class ClientTests( + HttpClientFixture httpClientFixture, + ConfigurationFixture configFixture +) : EndToEndTest(httpClientFixture, configFixture) +{ + [Fact] + public async Task CreateChatMessage_WhenCalled_ShouldReturnChatResponse() + { + var request = new ChatMessageRequest( + model: AnthropicModels.Claude3Haiku, + messages: [new(MessageRole.User, [new TextContent("Hello!")])] + ); + + var result = await _client.CreateChatMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeOfType(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/ConfigurationFixture.cs b/tests/AnthropicClient.Tests/EndToEnd/ConfigurationFixture.cs new file mode 100644 index 0000000..e487c71 --- /dev/null +++ b/tests/AnthropicClient.Tests/EndToEnd/ConfigurationFixture.cs @@ -0,0 +1,23 @@ +namespace AnthropicClient.Tests.EndToEnd; + +public class ConfigurationFixture +{ + public string AnthropicApiKey { get; } + + public ConfigurationFixture() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.Test.json") + .AddEnvironmentVariables() + .Build(); + + var anthropicApiKey = configuration["AnthropicApiKey"]; + + if (string.IsNullOrEmpty(anthropicApiKey)) + { + throw new InvalidOperationException("AnthropicApiKey is required"); + } + + AnthropicApiKey = anthropicApiKey; + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs b/tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs new file mode 100644 index 0000000..c808412 --- /dev/null +++ b/tests/AnthropicClient.Tests/EndToEnd/EndToEndTest.cs @@ -0,0 +1,9 @@ +namespace AnthropicClient.Tests.EndToEnd; + +public class EndToEndTest( + HttpClientFixture httpClientFixture, + ConfigurationFixture configFixture +) : IClassFixture, IClassFixture +{ + protected readonly Client _client = new(configFixture.AnthropicApiKey, httpClientFixture.HttpClient); +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/EndToEnd/HttpClientFixture.cs b/tests/AnthropicClient.Tests/EndToEnd/HttpClientFixture.cs new file mode 100644 index 0000000..53a632e --- /dev/null +++ b/tests/AnthropicClient.Tests/EndToEnd/HttpClientFixture.cs @@ -0,0 +1,11 @@ +namespace AnthropicClient.Tests.EndToEnd; + +public class HttpClientFixture +{ + public HttpClient HttpClient { get; } + + public HttpClientFixture() + { + HttpClient = new HttpClient(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicErrorTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicErrorTests.cs new file mode 100644 index 0000000..c2106b0 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicErrorTests.cs @@ -0,0 +1,168 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AnthropicErrorTests : SerializationTest +{ + private readonly string _testApiErrorJson = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""api_error"", + ""message"": ""message"" + } + }"; + + private readonly string _testAuthenticationErrorJson = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""authentication_error"", + ""message"": ""message"" + } + }"; + + private readonly string _testRateLimitErrorJson = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""rate_limit_error"", + ""message"": ""message"" + } + }"; + + private readonly string _testPermissionErrorJson = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""permission_error"", + ""message"": ""message"" + } + }"; + + private readonly string _testNotFoundErrorJson = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""not_found_error"", + ""message"": ""message"" + } + }"; + + private readonly string _testOverloadedErrorJson = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""overloaded_error"", + ""message"": ""message"" + } + }"; + + private readonly string _testInvalidRequestErrorJson = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""invalid_request_error"", + ""message"": ""message"" + } + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var apiError = new ApiError("message"); + + var error = new AnthropicError(apiError); + + error.Type.Should().Be("error"); + error.Error.Should().BeSameAs(apiError); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldBeExpectedShape() + { + var apiError = new ApiError("message"); + var error = new AnthropicError(apiError); + + var json = Serialize(error); + + JsonAssert.Equal(_testApiErrorJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModel() + { + var error = Deserialize(_testApiErrorJson); + + error!.Type.Should().Be("error"); + error.Error.Should().BeOfType(); + error.Error!.Message.Should().Be("message"); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModelForAuthenticationError() + { + var error = Deserialize(_testAuthenticationErrorJson); + + error!.Type.Should().Be("error"); + error.Error.Should().BeOfType(); + error.Error!.Message.Should().Be("message"); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModelForRateLimitError() + { + var error = Deserialize(_testRateLimitErrorJson); + + error!.Type.Should().Be("error"); + error.Error.Should().BeOfType(); + error.Error!.Message.Should().Be("message"); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModelForPermissionError() + { + var error = Deserialize(_testPermissionErrorJson); + + error!.Type.Should().Be("error"); + error.Error.Should().BeOfType(); + error.Error!.Message.Should().Be("message"); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModelForNotFoundError() + { + var error = Deserialize(_testNotFoundErrorJson); + + error!.Type.Should().Be("error"); + error.Error.Should().BeOfType(); + error.Error!.Message.Should().Be("message"); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModelForOverloadedError() + { + var error = Deserialize(_testOverloadedErrorJson); + + error!.Type.Should().Be("error"); + error.Error.Should().BeOfType(); + error.Error!.Message.Should().Be("message"); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModelForInvalidRequestError() + { + var error = Deserialize(_testInvalidRequestErrorJson); + + error!.Type.Should().Be("error"); + error.Error.Should().BeOfType(); + error.Error!.Message.Should().Be("message"); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldBeExpectedModelForUnknownError() + { + var json = @"{ + ""type"": ""error"", + ""error"": { + ""type"": ""unknown_error"", + ""message"": ""message"" + } + }"; + + var action = () => Deserialize(json); + + action.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs new file mode 100644 index 0000000..c28c358 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicModelsTests.cs @@ -0,0 +1,67 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AnthropicModelsTests +{ + [Fact] + public void Claude3Opus_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-3-opus-20240229"; + + var actual = AnthropicModels.Claude3Opus; + + actual.Should().Be(expected); + } + + [Fact] + public void Claude3Sonnet_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-3-sonnet-20240229"; + + var actual = AnthropicModels.Claude3Sonnet; + + actual.Should().Be(expected); + } + + [Fact] + public void Claude35Sonnet_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-3-5-sonnet-20240620"; + + var actual = AnthropicModels.Claude35Sonnet; + + actual.Should().Be(expected); + } + + [Fact] + public void Claude3Haiku_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "claude-3-haiku-20240307"; + + var actual = AnthropicModels.Claude3Haiku; + + actual.Should().Be(expected); + } + + [Fact] + public void Claude35Sonnet_WhenCalled_ItShouldExpectedValue() + { + var expected = "claude-3-5-sonnet-20240620"; + + var actual = AnthropicModels.Claude35Sonnet; + + actual.Should().Be(expected); + } + + [Theory] + [InlineData("claude-3-opus-20240229", true)] + [InlineData("claude-3-sonnet-20240229", true)] + [InlineData("claude-3-5-sonnet-20240620", true)] + [InlineData("claude-3-haiku-20240307", 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/AnthropicResultTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnthropicResultTests.cs new file mode 100644 index 0000000..715e03a --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AnthropicResultTests.cs @@ -0,0 +1,30 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AnthropicResultTests +{ + [Fact] + public void Success_WhenCalled_ItShouldReturnSuccessResult() + { + var value = "success"; + var requestId = Guid.NewGuid().ToString(); + + var actual = AnthropicResult.Success(value, requestId); + + actual.IsSuccess.Should().BeTrue(); + actual.Value.Should().Be(value); + actual.RequestId.Should().Be(requestId); + } + + [Fact] + public void Failure_WhenCalled_ItShouldReturnFailureResult() + { + var error = new AnthropicError(new AuthenticationError("message")); + var requestId = Guid.NewGuid().ToString(); + + var actual = AnthropicResult.Failure(error, requestId); + + actual.IsSuccess.Should().BeFalse(); + actual.Error.Should().Be(error); + actual.RequestId.Should().Be(requestId); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AnyToolChoiceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AnyToolChoiceTests.cs new file mode 100644 index 0000000..aac7e2c --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AnyToolChoiceTests.cs @@ -0,0 +1,38 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AnyToolChoiceTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""any"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetTypeToAny() + { + var expected = "any"; + + var actual = new AnyToolChoice(); + + actual.Type.Should().Be(expected); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var choice = new AnyToolChoice(); + + var actual = Serialize(choice); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new AnyToolChoice(); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AuthenticationErrorTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AuthenticationErrorTests.cs new file mode 100644 index 0000000..dbb2631 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AuthenticationErrorTests.cs @@ -0,0 +1,40 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AuthenticationErrorTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""message"": ""message"", + ""type"": ""authentication_error"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var expectedMessage = "message"; + + var actual = new AuthenticationError(expectedMessage); + + actual.Message.Should().Be(expectedMessage); + actual.Type.Should().Be("authentication_error"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var error = new AuthenticationError("message"); + + var actual = Serialize(error); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new AuthenticationError("message"); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/AutoToolChoiceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/AutoToolChoiceTests.cs new file mode 100644 index 0000000..f703eab --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/AutoToolChoiceTests.cs @@ -0,0 +1,38 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class AutoToolChoiceTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""auto"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetTypeToAuto() + { + var expected = "auto"; + + var actual = new AutoToolChoice(); + + actual.Type.Should().Be(expected); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var choice = new AutoToolChoice(); + + var actual = Serialize(choice); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new AutoToolChoice(); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ChatMessageRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ChatMessageRequestTests.cs new file mode 100644 index 0000000..3b269a9 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ChatMessageRequestTests.cs @@ -0,0 +1,653 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ChatMessageRequestTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""model"": ""claude-3-sonnet-20240229"", + ""system"": ""test-system"", + ""messages"": [ + { ""role"": ""user"", ""content"": [{ ""text"": ""Hello!"", ""type"": ""text"" }] } + ], + ""max_tokens"": 512, + ""metadata"": { ""test"":""test"" }, + ""stop_sequences"": [], + ""temperature"": 0.5, + ""topK"": 10, + ""topP"": 0.5, + ""tool_choice"": { ""type"":""auto"" }, + ""tools"": [ + { + ""name"": ""test-tool"", + ""description"": ""test-description"", + ""input_schema"": { + ""type"": ""object"", + ""properties"": { + ""test-property"": { + ""type"": ""string"", + ""description"": ""test-description"" + } + }, + ""required"": [""test-property""] + } + } + ], + ""stream"": false + }"; + + private readonly string _testJsonWithAnyToolChoice = @"{ + ""model"": ""claude-3-sonnet-20240229"", + ""system"": ""test-system"", + ""messages"":[ + { ""role"": ""user"", ""content"": [{ ""text"": ""Hello!"", ""type"":""text"" }] } + ], + ""max_tokens"": 512, + ""metadata"": {""test"":""test""}, + ""stop_sequences"": [], + ""temperature"": 0.5, + ""topK"": 10, + ""topP"": 0.5, + ""tool_choice"": {""type"":""any""}, + ""tools"": [ + { + ""name"": ""test-tool"", + ""description"": ""test-description"", + ""input_schema"": { + ""type"": ""object"", + ""properties"": { + ""test-property"": { + ""type"": ""string"", + ""description"": ""test-description"" + } + }, + ""required"": [""test-property""] + } + } + ], + ""stream"":false + }"; + + private readonly string _testJsonWithSpecificToolChoice = @"{ + ""model"": ""claude-3-sonnet-20240229"", + ""system"": ""test-system"", + ""messages"": [ + { ""role"": ""user"", ""content"": [{ ""text"": ""Hello!"", ""type"": ""text"" }] } + ], + ""max_tokens"": 512, + ""metadata"": { ""test"": ""test"" }, + ""stop_sequences"": [], + ""temperature"": 0.5, + ""topK"": 10, + ""topP"": 0.5, + ""tool_choice"": { ""type"": ""tool"", ""name"": ""test-tool"" }, + ""tools"": [ + { + ""name"": ""test-tool"", + ""description"": ""test-description"", + ""input_schema"": { + ""type"": ""object"", + ""properties"": { + ""test-property"": { + ""type"": ""string"", + ""description"": ""test-description"" + } + }, + ""required"": [""test-property""] + } + } + ], + ""stream"": false + }"; + + private readonly string _testJsonWithImageContent = @"{ + ""model"": ""claude-3-sonnet-20240229"", + ""system"": ""test-system"", + ""messages"":[ + { + ""role"": ""user"", + ""content"": [ + { + ""type"": ""image"", + ""source"": { ""media_type"": ""image/jpeg"", ""data"": ""data"" } + } + ] + } + ], + ""max_tokens"": 512, + ""metadata"": { ""test"": ""test"" }, + ""stop_sequences"": [], + ""temperature"": 0.5, + ""topK"": 10, + ""topP"": 0.5, + ""tool_choice"": { ""type"": ""auto"" }, + ""tools"":[ + { + ""name"": ""test-tool"", + ""description"": ""test-description"", + ""input_schema"": { + ""type"": ""object"", + ""properties"": { + ""test-property"": { + ""type"": ""string"", + ""description"": ""test-description"" + } + }, + ""required"": [""test-property""] + } + } + ], + ""stream"": false + }"; + + private readonly string _testJsonWithUnknownContent = @"{ + ""model"": ""claude-3-sonnet-20240229"", + ""system"": ""test-system"", + ""messages"": [{ ""role"": ""user"", ""content"": [{ ""type"": ""unknown"", ""text"": ""text"" }] }], + ""max_tokens"": 512, + ""metadata"": { ""test"": ""test"" }, + ""stop_sequences"": [], + ""temperature"": 0.5, + ""topK"": 10, + ""topP"": 0.5, + ""tool_choice"": { ""type"":""auto"" }, + ""tools"": [ + { + ""name"": ""test-tool"", + ""description"": ""test-description"", + ""input_schema"": { + ""type"": ""object"", + ""properties"": { + ""test-property"": { + ""type"": ""string"", + ""description"": ""test-description"" + } + }, + ""required"": [""test-property""] + } + } + ], + ""stream"": false + }"; + + private readonly string _testJsonWithToolUseContent = @"{ + ""model"": ""claude-3-sonnet-20240229"", + ""system"": ""test-system"", + ""messages"": [ + { + ""role"": ""assistant"", + ""content"": [ + { + ""type"": ""tool_use"", + ""name"": ""test-tool"", + ""id"": ""test-tool-id"", + ""input"": { + ""test-property"": ""test-value"" + } + } + ] + } + ], + ""max_tokens"": 512, + ""metadata"": { ""test"": ""test"" }, + ""stop_sequences"": [], + ""temperature"": 0.5, + ""topK"": 10, + ""topP"": 0.5, + ""tool_choice"": { ""type"": ""auto"" }, + ""tools"": [ + { + ""name"": ""test-tool"", + ""description"": ""test-description"", + ""input_schema"": { + ""type"": ""object"", + ""properties"": { + ""test-property"": { + ""type"": ""string"", + ""description"": ""test-description"" + } + }, + ""required"": [""test-property""] + } + } + ], + ""stream"": false + }"; + + private readonly string _testJsonWithToolResultContent = @"{ + ""model"": ""claude-3-sonnet-20240229"", + ""system"": ""test-system"", + ""messages"": [ + { + ""role"": ""assistant"", + ""content"": [ + { + ""type"": ""tool_result"", + ""tool_use_id"": ""test-tool"", + ""content"": ""test-value"" + } + ] + } + ], + ""max_tokens"": 512, + ""metadata"": { ""test"": ""test"" }, + ""stop_sequences"": [], + ""temperature"": 0.5, + ""topK"": 10, + ""topP"": 0.5, + ""tool_choice"": { ""type"": ""auto"" }, + ""tools"": [ + { + ""name"": ""test-tool"", + ""description"": ""test-description"", + ""input_schema"": { + ""type"": ""object"", + ""properties"": { + ""test-property"": { + ""type"": ""string"", + ""description"": ""test-description"" + } + }, + ""required"": [""test-property""] + } + } + ], + ""stream"":false + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var model = AnthropicModels.Claude3Sonnet; + var messages = new List { new() }; + var maxTokens = 512; + var system = "test-system"; + var metadata = new Dictionary { ["test"] = "test" }; + var temperature = 0.5m; + var topK = 10; + var topP = 0.5m; + var toolChoice = new AutoToolChoice(); + var tools = new List { new() }; + + var chatMessageRequest = new ChatMessageRequest( + model: model, + messages: messages, + maxTokens: maxTokens, + system: system, + metadata: metadata, + temperature: temperature, + topK: topK, + topP: topP, + toolChoice: toolChoice, + tools: tools + ); + + chatMessageRequest.Model.Should().Be(model); + chatMessageRequest.Messages.Should().BeSameAs(messages); + chatMessageRequest.MaxTokens.Should().Be(maxTokens); + chatMessageRequest.System.Should().Be(system); + chatMessageRequest.Metadata.Should().BeSameAs(metadata); + chatMessageRequest.Temperature.Should().Be(temperature); + chatMessageRequest.TopK.Should().Be(topK); + chatMessageRequest.TopP.Should().Be(topP); + chatMessageRequest.ToolChoice.Should().Be(toolChoice); + chatMessageRequest.Tools.Should().BeSameAs(tools); + chatMessageRequest.Stream.Should().BeFalse(); + } + + [Fact] + public void Constructor_WhenCalledAndModelIsNull_ItShouldThrowArgumentNullException() + { + var action = () => new ChatMessageRequest( + model: null!, + messages: [new()] + ); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndMessagesIsNull_ItShouldThrowArgumentNullException() + { + var action = () => new ChatMessageRequest( + model: AnthropicModels.Claude3Sonnet, + messages: null! + ); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndModelIsInvalid_ItShouldThrowArgumentException() + { + var action = () => new ChatMessageRequest( + model: "invalid-model", + messages: [new()] + ); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndMessagesIsEmpty_ItShouldThrowArgumentException() + { + var action = () => new ChatMessageRequest( + model: AnthropicModels.Claude3Sonnet, + messages: [] + ); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndMaxTokensIsInvalid_ItShouldThrowArgumentException() + { + var action = () => new ChatMessageRequest( + model: AnthropicModels.Claude3Sonnet, + messages: [new()], + maxTokens: 0 + ); + + action.Should().Throw(); + } + + [Theory] + [InlineData(-1)] + [InlineData(2)] + public void Constructor_WhenCalledAndTemperatureIsInvalid_ItShouldThrowArgumentException(decimal temperature) + { + var action = () => new ChatMessageRequest( + model: AnthropicModels.Claude3Sonnet, + messages: [new()], + temperature: temperature + ); + + 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 maxTokens = 512; + var system = "test-system"; + var metadata = new Dictionary + { + ["test"] = "test" + }; + var temperature = 0.5m; + var topK = 10; + var topP = 0.5m; + var toolChoice = new AutoToolChoice(); + var tools = new List + { + new() + { + Name = "test-tool", + Description = "test-description", + InputSchema = new InputSchema( + properties: new Dictionary + { + ["test-property"] = new InputProperty( + type: "string", + description: "test-description" + ) + }, + required: ["test-property"] + ), + } + }; + + var chatMessageRequest = new ChatMessageRequest( + model: model, + messages: messages, + maxTokens: maxTokens, + system: system, + metadata: metadata, + temperature: temperature, + topK: topK, + topP: topP, + toolChoice: toolChoice, + tools: tools + ); + + var actual = Serialize(chatMessageRequest); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var chatMessageRequest = Deserialize(_testJson); + + chatMessageRequest!.Model.Should().Be(AnthropicModels.Claude3Sonnet); + chatMessageRequest.System.Should().Be("test-system"); + chatMessageRequest.Messages.Should().HaveCount(1); + chatMessageRequest.MaxTokens.Should().Be(512); + chatMessageRequest.Metadata.Should().HaveCount(1); + + var testValue = chatMessageRequest.Metadata!.GetValueOrDefault("test")!.ToString(); + testValue.Should().Be("test"); + + chatMessageRequest.Temperature.Should().Be(0.5m); + chatMessageRequest.TopK.Should().Be(10); + chatMessageRequest.TopP.Should().Be(0.5m); + chatMessageRequest.ToolChoice.Should().BeOfType(); + chatMessageRequest.ToolChoice!.Type.Should().Be("auto"); + chatMessageRequest.Tools.Should().HaveCount(1); + chatMessageRequest.Tools![0].Name.Should().Be("test-tool"); + chatMessageRequest.Tools[0].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Type.Should().Be("object"); + chatMessageRequest.Tools[0].InputSchema.Properties.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Type.Should().Be("string"); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Required.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Required[0].Should().Be("test-property"); + chatMessageRequest.Stream.Should().BeFalse(); + } + + [Fact] + public void JsonDeserialization_WhenDeserializedWithAnyToolChoice_ItShouldHaveExpectedShape() + { + var chatMessageRequest = Deserialize(_testJsonWithAnyToolChoice); + + chatMessageRequest!.Model.Should().Be(AnthropicModels.Claude3Sonnet); + chatMessageRequest.System.Should().Be("test-system"); + chatMessageRequest.Messages.Should().HaveCount(1); + chatMessageRequest.MaxTokens.Should().Be(512); + chatMessageRequest.Metadata.Should().HaveCount(1); + + var testValue = chatMessageRequest.Metadata!.GetValueOrDefault("test")!.ToString(); + testValue.Should().Be("test"); + + chatMessageRequest.Temperature.Should().Be(0.5m); + chatMessageRequest.TopK.Should().Be(10); + chatMessageRequest.TopP.Should().Be(0.5m); + chatMessageRequest.ToolChoice.Should().BeOfType(); + chatMessageRequest.ToolChoice!.Type.Should().Be("any"); + chatMessageRequest.Tools.Should().HaveCount(1); + chatMessageRequest.Tools![0].Name.Should().Be("test-tool"); + chatMessageRequest.Tools[0].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Type.Should().Be("object"); + chatMessageRequest.Tools[0].InputSchema.Properties.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Type.Should().Be("string"); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Required.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Required[0].Should().Be("test-property"); + } + + [Fact] + public void JsonDeserialization_WhenDeserializedWithSpecificToolChoice_ItShouldHaveExpectedShape() + { + var chatMessageRequest = Deserialize(_testJsonWithSpecificToolChoice); + + chatMessageRequest!.Model.Should().Be(AnthropicModels.Claude3Sonnet); + chatMessageRequest.System.Should().Be("test-system"); + chatMessageRequest.Messages.Should().HaveCount(1); + chatMessageRequest.MaxTokens.Should().Be(512); + chatMessageRequest.Metadata.Should().HaveCount(1); + + var testValue = chatMessageRequest.Metadata!.GetValueOrDefault("test")!.ToString(); + testValue.Should().Be("test"); + + chatMessageRequest.Temperature.Should().Be(0.5m); + chatMessageRequest.TopK.Should().Be(10); + chatMessageRequest.TopP.Should().Be(0.5m); + chatMessageRequest.ToolChoice.Should().BeOfType(); + + var specificToolChoice = chatMessageRequest.ToolChoice as SpecificToolChoice; + specificToolChoice!.Type.Should().Be("tool"); + specificToolChoice.Name.Should().Be("test-tool"); + chatMessageRequest.Tools.Should().HaveCount(1); + chatMessageRequest.Tools![0].Name.Should().Be("test-tool"); + chatMessageRequest.Tools[0].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Type.Should().Be("object"); + chatMessageRequest.Tools[0].InputSchema.Properties.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Type.Should().Be("string"); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Required.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Required[0].Should().Be("test-property"); + } + + [Fact] + public void JsonDeserialization_WhenDeserializedWithUnknownToolChoice_ItShouldThrowJsonException() + { + var json = @"{""model"":""claude-3-sonnet-20240229"",""system"":""test-system"",""messages"":[{""role"":""user"",""content"":[{""text"":""Hello!"",""type"":""text""}]}],""max_tokens"":512,""metadata"":{""test"":""test""},""stop_sequences"":[],""temperature"":0.5,""topK"":10,""topP"":0.5,""tool_choice"":{""type"":""unknown""},""tools"":[{""name"":""test-tool"",""description"":""test-description"",""input_schema"":{""type"":""object"",""properties"":{""test-property"":{""type"":""string"",""description"":""test-description""}},""required"":[""test-property""]}}],""stream"":false}"; + + var action = () => Deserialize(json); + + action.Should().Throw(); + } + + [Fact] + public void JsonDeserialization_WhenDeserializedWithImageContent_ItShouldHaveExpectedShape() + { + var chatMessageRequest = Deserialize(_testJsonWithImageContent); + + chatMessageRequest!.Model.Should().Be(AnthropicModels.Claude3Sonnet); + chatMessageRequest.System.Should().Be("test-system"); + chatMessageRequest.Messages.Should().HaveCount(1); + chatMessageRequest.MaxTokens.Should().Be(512); + chatMessageRequest.Metadata.Should().HaveCount(1); + + var testValue = chatMessageRequest.Metadata!.GetValueOrDefault("test")!.ToString(); + testValue.Should().Be("test"); + + chatMessageRequest.Temperature.Should().Be(0.5m); + chatMessageRequest.TopK.Should().Be(10); + chatMessageRequest.TopP.Should().Be(0.5m); + chatMessageRequest.ToolChoice.Should().BeOfType(); + chatMessageRequest.ToolChoice!.Type.Should().Be("auto"); + chatMessageRequest.Tools.Should().HaveCount(1); + chatMessageRequest.Tools![0].Name.Should().Be("test-tool"); + chatMessageRequest.Tools[0].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Type.Should().Be("object"); + chatMessageRequest.Tools[0].InputSchema.Properties.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Type.Should().Be("string"); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Required.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Required[0].Should().Be("test-property"); + + chatMessageRequest.Messages[0].Content.Should().HaveCount(1); + chatMessageRequest.Messages[0].Content[0].Should().BeOfType(); + + var imageContent = chatMessageRequest.Messages[0].Content[0] as ImageContent; + imageContent!.Type.Should().Be("image"); + imageContent.Source.MediaType.Should().Be("image/jpeg"); + imageContent.Source.Data.Should().Be("data"); + } + + [Fact] + public void JsonDeserialization_WhenDeserializedWithToolUseContent_ItShouldHaveExpectedShape() + { + var chatMessageRequest = Deserialize(_testJsonWithToolUseContent); + + chatMessageRequest!.Model.Should().Be(AnthropicModels.Claude3Sonnet); + chatMessageRequest.System.Should().Be("test-system"); + chatMessageRequest.Messages.Should().HaveCount(1); + chatMessageRequest.MaxTokens.Should().Be(512); + chatMessageRequest.Metadata.Should().HaveCount(1); + + var testValue = chatMessageRequest.Metadata!.GetValueOrDefault("test")!.ToString(); + testValue.Should().Be("test"); + + chatMessageRequest.Temperature.Should().Be(0.5m); + chatMessageRequest.TopK.Should().Be(10); + chatMessageRequest.TopP.Should().Be(0.5m); + chatMessageRequest.ToolChoice.Should().BeOfType(); + chatMessageRequest.ToolChoice!.Type.Should().Be("auto"); + chatMessageRequest.Tools.Should().HaveCount(1); + chatMessageRequest.Tools![0].Name.Should().Be("test-tool"); + chatMessageRequest.Tools[0].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Type.Should().Be("object"); + chatMessageRequest.Tools[0].InputSchema.Properties.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Type.Should().Be("string"); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Required.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Required[0].Should().Be("test-property"); + + chatMessageRequest.Messages[0].Content.Should().HaveCount(1); + chatMessageRequest.Messages[0].Content[0].Should().BeOfType(); + + var toolUseContent = chatMessageRequest.Messages[0].Content[0] as ToolUseContent; + toolUseContent!.Type.Should().Be("tool_use"); + toolUseContent.Name.Should().Be("test-tool"); + toolUseContent.Id.Should().Be("test-tool-id"); + toolUseContent.Input.Should().HaveCount(1); + toolUseContent.Input["test-property"]!.ToString().Should().Be("test-value"); + } + + [Fact] + public void JsonDeserialization_WhenDeserializedWithToolResultContent_ItShouldHaveExpectedShape() + { + var chatMessageRequest = Deserialize(_testJsonWithToolResultContent); + + chatMessageRequest!.Model.Should().Be(AnthropicModels.Claude3Sonnet); + chatMessageRequest.System.Should().Be("test-system"); + chatMessageRequest.Messages.Should().HaveCount(1); + chatMessageRequest.MaxTokens.Should().Be(512); + chatMessageRequest.Metadata.Should().HaveCount(1); + + var testValue = chatMessageRequest.Metadata!.GetValueOrDefault("test")!.ToString(); + testValue.Should().Be("test"); + + chatMessageRequest.Temperature.Should().Be(0.5m); + chatMessageRequest.TopK.Should().Be(10); + chatMessageRequest.TopP.Should().Be(0.5m); + chatMessageRequest.ToolChoice.Should().BeOfType(); + chatMessageRequest.ToolChoice!.Type.Should().Be("auto"); + chatMessageRequest.Tools.Should().HaveCount(1); + chatMessageRequest.Tools![0].Name.Should().Be("test-tool"); + chatMessageRequest.Tools[0].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Type.Should().Be("object"); + chatMessageRequest.Tools[0].InputSchema.Properties.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Type.Should().Be("string"); + chatMessageRequest.Tools[0].InputSchema.Properties["test-property"].Description.Should().Be("test-description"); + chatMessageRequest.Tools[0].InputSchema.Required.Should().HaveCount(1); + chatMessageRequest.Tools[0].InputSchema.Required[0].Should().Be("test-property"); + + chatMessageRequest.Messages[0].Content.Should().HaveCount(1); + chatMessageRequest.Messages[0].Content[0].Should().BeOfType(); + + var toolResultContent = chatMessageRequest.Messages[0].Content[0] as ToolResultContent; + toolResultContent!.Type.Should().Be("tool_result"); + toolResultContent.ToolUseId.Should().Be("test-tool"); + toolResultContent.Content.Should().Be("test-value"); + } + + [Fact] + public void JsonDeserialization_WhenDeserializedWithUnknownContent_ItShouldThrowJsonException() + { + var action = () => Deserialize(_testJsonWithUnknownContent); + + action.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ChatMessageTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ChatMessageTests.cs new file mode 100644 index 0000000..7554db7 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ChatMessageTests.cs @@ -0,0 +1,78 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ChatMessageTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""role"": ""assistant"", + ""content"": [ + { ""text"": ""text"", ""type"": ""text"" } + ] + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var role = "assistant"; + var content = new List { new TextContent("text") }; + + var chatMessage = new ChatMessage(role, content); + + chatMessage.Role.Should().Be(role); + chatMessage.Content.Should().BeSameAs(content); + } + + [Fact] + public void Constructor_WhenCalledAndRoleIsNull_ItShouldThrowArgumentNullException() + { + var content = new List { new TextContent("text") }; + + var action = () => new ChatMessage(null!, content); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndContentIsNull_ItShouldThrowArgumentNullException() + { + var role = "assistant"; + + var action = () => new ChatMessage(role, null!); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndRoleIsInvalid_ItShouldThrowArgumentException() + { + var role = "invalid"; + var content = new List { new TextContent("text") }; + + var action = () => new ChatMessage(role, content); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldReturnJsonString() + { + var role = "assistant"; + var content = new List { new TextContent("text") }; + var chatMessage = new ChatMessage(role, content); + + var actual = Serialize(chatMessage); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldReturnChatMessage() + { + var chatMessage = Deserialize(_testJson); + + chatMessage.Should().NotBeNull(); + chatMessage!.Role.Should().Be("assistant"); + chatMessage.Content.Should().HaveCount(1); + chatMessage.Content[0].Should().BeOfType(); + chatMessage.Content[0].As().Text.Should().Be("text"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ChatResponseTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ChatResponseTests.cs new file mode 100644 index 0000000..38a9b78 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ChatResponseTests.cs @@ -0,0 +1,120 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ChatResponseTests : SerializationTest +{ + [Fact] + public void Constructor_WhenCalled_ShouldInitializeProperties() + { + var expectedId = "id"; + var expectedModel = "model"; + var expectedRole = "role"; + var expectedStopReason = "stop reason"; + var expectedStopSequence = "stop sequence"; + var expectedType = "type"; + var expectedUsage = new ChatUsage + { + InputTokens = 1, + OutputTokens = 2 + }; + var expectedContent = new List + { + new TextContent("text content"), + }; + + var chatResponse = new ChatResponse + { + Id = expectedId, + Model = expectedModel, + Role = expectedRole, + StopReason = expectedStopReason, + StopSequence = expectedStopSequence, + Type = expectedType, + Usage = expectedUsage, + Content = expectedContent + }; + + chatResponse.Id.Should().Be(expectedId); + chatResponse.Model.Should().Be(expectedModel); + chatResponse.Role.Should().Be(expectedRole); + chatResponse.StopReason.Should().Be(expectedStopReason); + chatResponse.StopSequence.Should().Be(expectedStopSequence); + chatResponse.Type.Should().Be(expectedType); + chatResponse.Usage.Should().BeEquivalentTo(expectedUsage); + chatResponse.Content.Should().BeEquivalentTo(expectedContent); + } + + [Fact] + public void JsonSerialization_WhenCalled_ItShouldSerializeCorrectly() + { + var expectedJson = @"{ + ""id"": ""id"", + ""model"": ""model"", + ""role"": ""role"", + ""stop_reason"": ""stop reason"", + ""stop_sequence"": ""stop sequence"", + ""type"": ""type"", + ""usage"": { ""input_tokens"": 1, ""output_tokens"": 2 }, + ""content"": [ + { ""text"": ""text content"", ""type"": ""text"" } + ] + }"; + + var chatResponse = new ChatResponse + { + Id = "id", + Model = "model", + Role = "role", + StopReason = "stop reason", + StopSequence = "stop sequence", + Type = "type", + Usage = new ChatUsage + { + InputTokens = 1, + OutputTokens = 2 + }, + Content = + [ + new TextContent("text content"), + ] + }; + + var actual = Serialize(chatResponse); + + JsonAssert.Equal(expectedJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenCalled_ItShouldDeserializeCorrectly() + { + var json = @"{ + ""id"": ""id"", + ""model"": ""model"", + ""role"": ""role"", + ""stop_reason"": ""stop reason"", + ""stop_sequence"": ""stop sequence"", + ""type"": ""type"", + ""usage"": { ""input_tokens"": 1, ""output_tokens"": 2 }, + ""content"": [ + { ""text"": ""text content"", ""type"": ""text"" } + ] + }"; + + var chatResponse = Deserialize(json); + + chatResponse!.Id.Should().Be("id"); + chatResponse.Model.Should().Be("model"); + chatResponse.Role.Should().Be("role"); + chatResponse.StopReason.Should().Be("stop reason"); + chatResponse.StopSequence.Should().Be("stop sequence"); + chatResponse.Type.Should().Be("type"); + chatResponse.Usage.Should().BeEquivalentTo(new ChatUsage + { + InputTokens = 1, + OutputTokens = 2 + }); + chatResponse.Content.Should().BeEquivalentTo(new List + { + new TextContent("text content"), + }); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ChatUsageTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ChatUsageTests.cs new file mode 100644 index 0000000..aae5572 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ChatUsageTests.cs @@ -0,0 +1,47 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ChatUsageTests : SerializationTest +{ + [Fact] + public void Constructor_WhenCalled_ShouldInitializeProperties() + { + var expectedInputTokens = 1; + var expectedOutputTokens = 2; + + var chatUsage = new ChatUsage + { + InputTokens = expectedInputTokens, + OutputTokens = expectedOutputTokens + }; + + chatUsage.InputTokens.Should().Be(expectedInputTokens); + chatUsage.OutputTokens.Should().Be(expectedOutputTokens); + } + + [Fact] + public void JsonSerialization_WhenCalled_ItShouldSerializeCorrectly() + { + var expectedJson = @"{ ""input_tokens"": 1, ""output_tokens"": 2 }"; + + var chatUsage = new ChatUsage + { + InputTokens = 1, + OutputTokens = 2 + }; + + var actual = Serialize(chatUsage); + + JsonAssert.Equal(expectedJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenCalled_ItShouldDeserializeCorrectly() + { + var json = @"{ ""input_tokens"": 1, ""output_tokens"": 2 }"; + + var chatUsage = Deserialize(json); + + chatUsage!.InputTokens.Should().Be(1); + chatUsage.OutputTokens.Should().Be(2); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ContentTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ContentTypeTests.cs new file mode 100644 index 0000000..51362fb --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ContentTypeTests.cs @@ -0,0 +1,44 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ContentTypeTests +{ + [Fact] + public void Text_WhenCalled_ItShouldReturnText() + { + var expected = "text"; + + var actual = ContentType.Text; + + actual.Should().Be(expected); + } + + [Fact] + public void Image_WhenCalled_ItShouldReturnImage() + { + var expected = "image"; + + var actual = ContentType.Image; + + actual.Should().Be(expected); + } + + [Fact] + public void ToolUse_WhenCalled_ItShouldReturnToolUse() + { + var expected = "tool_use"; + + var actual = ContentType.ToolUse; + + actual.Should().Be(expected); + } + + [Fact] + public void ToolResult_WhenCalled_ItShouldReturnToolResult() + { + var expected = "tool_result"; + + var actual = ContentType.ToolResult; + + actual.Should().Be(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs new file mode 100644 index 0000000..f635d0f --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ImageContentTests.cs @@ -0,0 +1,74 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ImageContentTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""source"": { + ""media_type"": ""image/png"", + ""data"": ""data"" + }, + ""type"": ""image"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeSource() + { + var expectedMediaType = "image/png"; + var expectedData = "data"; + + var result = new ImageContent(expectedMediaType, expectedData); + + result.Source.Should().BeEquivalentTo(new ImageSource(expectedMediaType, expectedData)); + } + + [Fact] + public void Constructor_WhenCalledAndMediaTypeIsNull_ItShouldThrowArgumentNullException() + { + var expectedData = "data"; + + var action = () => new ImageContent(null!, expectedData); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndDataIsNull_ItShouldThrowArgumentNullException() + { + var expectedMediaType = "image/png"; + + var action = () => new ImageContent(expectedMediaType, null!); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithInvalidMediaType_ItShouldThrowArgumentException() + { + var expectedMediaType = "invalid"; + var expectedData = "data"; + + var action = () => new ImageContent(expectedMediaType, expectedData); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var content = new ImageContent("image/png", "data"); + + var actual = Serialize(content); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new ImageContent("image/png", "data"); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ImageTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ImageTypeTests.cs new file mode 100644 index 0000000..841632b --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ImageTypeTests.cs @@ -0,0 +1,57 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ImageTypeTests +{ + [Fact] + public void Jpg_WhenCalled_ItShouldReturnJpg() + { + var expected = "image/jpeg"; + + var actual = ImageType.Jpg; + + actual.Should().Be(expected); + } + + [Fact] + public void Png_WhenCalled_ItShouldReturnPng() + { + var expected = "image/png"; + + var actual = ImageType.Png; + + actual.Should().Be(expected); + } + + [Fact] + public void Gif_WhenCalled_ItShouldReturnGif() + { + var expected = "image/gif"; + + var actual = ImageType.Gif; + + actual.Should().Be(expected); + } + + [Fact] + public void Webp_WhenCalled_ItShouldReturnWebp() + { + var expected = "image/webp"; + + var actual = ImageType.Webp; + + actual.Should().Be(expected); + } + + [Theory] + [InlineData("image/jpeg", true)] + [InlineData("image/png", true)] + [InlineData("image/gif", true)] + [InlineData("image/webp", true)] + [InlineData("invalid", false)] + public void IsValidImageType_WhenCalled_ItShouldReturnExpectedValue(string imageType, bool expected) + { + var actual = ImageType.IsValidImageType(imageType); + + actual.Should().Be(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/InputPropertyTests.cs b/tests/AnthropicClient.Tests/Unit/Models/InputPropertyTests.cs new file mode 100644 index 0000000..8041e8d --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/InputPropertyTests.cs @@ -0,0 +1,60 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class InputPropertyTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""type"", + ""description"": ""description"" + }"; + + [Fact] + public void Constructor_WhenCalled_ShouldInitializeProperties() + { + var type = "type"; + var description = "description"; + + var inputProperty = new InputProperty(type, description); + + inputProperty.Type.Should().Be(type); + inputProperty.Description.Should().Be(description); + } + + [Fact] + public void Constructor_WhenTypeIsNull_ShouldThrowArgumentNullException() + { + var description = "description"; + + var action = () => new InputProperty(null!, description); + + action.Should().Throw().WithMessage("Value cannot be null. (Parameter 'type')"); + } + + [Fact] + public void Constructor_WhenDescriptionIsNull_ShouldThrowArgumentNullException() + { + var type = "type"; + + var action = () => new InputProperty(type, null!); + + action.Should().Throw().WithMessage("Value cannot be null. (Parameter 'description')"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var inputProperty = new InputProperty("type", "description"); + + var json = Serialize(inputProperty); + + JsonAssert.Equal(_testJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var inputProperty = Deserialize(_testJson); + + inputProperty!.Type.Should().Be("type"); + inputProperty.Description.Should().Be("description"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/InputSchemaTests.cs b/tests/AnthropicClient.Tests/Unit/Models/InputSchemaTests.cs new file mode 100644 index 0000000..9ceabb1 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/InputSchemaTests.cs @@ -0,0 +1,116 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class InputSchemaTests : SerializationTest +{ + private readonly string _testJson = @$"{{ + ""type"": ""object"", + ""properties"": {{ + ""property1"": {{ ""type"": ""type1"", ""description"": ""description1"" }}, + ""property2"": {{ ""type"": ""type2"", ""description"": ""description2"" }} + }}, + ""required"": [""property1""] + }}"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var inputProperties = new Dictionary + { + { "property1", new InputProperty("type1", "description1") }, + { "property2", new InputProperty("type2", "description2") } + }; + + var required = new List { "property1" }; + + var inputSchema = new InputSchema( + properties: inputProperties, + required: required + ); + + inputSchema.Type.Should().Be("object"); + inputSchema.Properties.Should().BeSameAs(inputProperties); + inputSchema.Required.Should().BeSameAs(required); + } + + [Fact] + public void Constructor_WhenPropertiesIsNull_ItShouldThrowArgumentNullException() + { + var required = new List { "property1" }; + + var action = () => new InputSchema(null!, required); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenRequiredIsNull_ItShouldThrowArgumentNullException() + { + var inputProperties = new Dictionary + { + { "property1", new InputProperty("type1", "description1") }, + { "property2", new InputProperty("type2", "description2") } + }; + + var action = () => new InputSchema(inputProperties, null!); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenRequiredPropertyIsNotInProperties_ItShouldThrowArgumentException() + { + var inputProperties = new Dictionary + { + { "property1", new InputProperty("type1", "description1") }, + { "property2", new InputProperty("type2", "description2") } + }; + + var required = new List { "property3" }; + + var action = () => new InputSchema(inputProperties, required); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var inputProperties = new Dictionary + { + { "property1", new InputProperty("type1", "description1") }, + { "property2", new InputProperty("type2", "description2") } + }; + + var required = new List { "property1" }; + + var inputSchema = new InputSchema( + properties: inputProperties, + required: required + ); + + var actual = Serialize(inputSchema); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var inputProperties = new Dictionary + { + { "property1", new InputProperty("type1", "description1") }, + { "property2", new InputProperty("type2", "description2") } + }; + + var required = new List { "property1" }; + + var expected = new InputSchema( + properties: inputProperties, + required: required + ); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/InvalidRequestErrorTests.cs b/tests/AnthropicClient.Tests/Unit/Models/InvalidRequestErrorTests.cs new file mode 100644 index 0000000..9f61427 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/InvalidRequestErrorTests.cs @@ -0,0 +1,47 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class InvalidRequestErrorTests : SerializationTest +{ + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var message = "message"; + + var error = new InvalidRequestError(message); + + error.Type.Should().Be("invalid_request_error"); + error.Message.Should().Be(message); + } + + [Fact] + public void Serialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var message = "message"; + + var error = new InvalidRequestError(message); + + var serialized = Serialize(error); + + JsonAssert.Equal( + @"{ + ""type"": ""invalid_request_error"", + ""message"": ""message"" + }", + serialized + ); + } + + [Fact] + public void Deserialization_WhenDeserialized_ItShouldHaveExpectedProperties() + { + var json = @"{ + ""type"": ""invalid_request_error"", + ""message"": ""message"" + }"; + + var error = Deserialize(json); + + error!.Type.Should().Be("invalid_request_error"); + error.Message.Should().Be("message"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageRoleTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageRoleTests.cs new file mode 100644 index 0000000..0e7b60d --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageRoleTests.cs @@ -0,0 +1,35 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class MessageRoleTests +{ + [Fact] + public void User_WhenCalled_ItShouldReturnUser() + { + var expected = "user"; + + var actual = MessageRole.User; + + actual.Should().Be(expected); + } + + [Fact] + public void Assistant_WhenCalled_ItShouldReturnAssistant() + { + var expected = "assistant"; + + var actual = MessageRole.Assistant; + + actual.Should().Be(expected); + } + + [Theory] + [InlineData("user", true)] + [InlineData("assistant", true)] + [InlineData("invalid", false)] + public void IsValidRole_WhenCalled_ItShouldReturnExpectedValue(string role, bool expected) + { + var actual = MessageRole.IsValidRole(role); + + actual.Should().Be(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/NotFoundErrorTests.cs b/tests/AnthropicClient.Tests/Unit/Models/NotFoundErrorTests.cs new file mode 100644 index 0000000..59ae63b --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/NotFoundErrorTests.cs @@ -0,0 +1,40 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class NotFoundErrorTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""message"": ""message"", + ""type"": ""not_found_error"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var expectedMessage = "message"; + + var actual = new NotFoundError(expectedMessage); + + actual.Message.Should().Be(expectedMessage); + actual.Type.Should().Be("not_found_error"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var error = new NotFoundError("message"); + + var actual = Serialize(error); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new NotFoundError("message"); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/OverloadedErrorTests.cs b/tests/AnthropicClient.Tests/Unit/Models/OverloadedErrorTests.cs new file mode 100644 index 0000000..de53f29 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/OverloadedErrorTests.cs @@ -0,0 +1,40 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class OverloadedErrorTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""message"": ""message"", + ""type"": ""overloaded_error"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var expectedMessage = "message"; + + var actual = new OverloadedError(expectedMessage); + + actual.Message.Should().Be(expectedMessage); + actual.Type.Should().Be("overloaded_error"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var error = new OverloadedError("message"); + + var actual = Serialize(error); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new OverloadedError("message"); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/PermissionErrorTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PermissionErrorTests.cs new file mode 100644 index 0000000..619be3d --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/PermissionErrorTests.cs @@ -0,0 +1,40 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class PermissionErrorTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""message"": ""message"", + ""type"": ""permission_error"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var expectedMessage = "message"; + + var actual = new PermissionError(expectedMessage); + + actual.Message.Should().Be(expectedMessage); + actual.Type.Should().Be("permission_error"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var error = new PermissionError("message"); + + var actual = Serialize(error); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new PermissionError("message"); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/RateLimitErrorTests.cs b/tests/AnthropicClient.Tests/Unit/Models/RateLimitErrorTests.cs new file mode 100644 index 0000000..0cab290 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/RateLimitErrorTests.cs @@ -0,0 +1,40 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class RateLimitErrorTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""message"": ""message"", + ""type"": ""rate_limit_error"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var expectedMessage = "message"; + + var actual = new RateLimitError(expectedMessage); + + actual.Message.Should().Be(expectedMessage); + actual.Type.Should().Be("rate_limit_error"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var error = new RateLimitError("message"); + + var actual = Serialize(error); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new RateLimitError("message"); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/SpecificToolChoiceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/SpecificToolChoiceTests.cs new file mode 100644 index 0000000..ae40197 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/SpecificToolChoiceTests.cs @@ -0,0 +1,51 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class SpecificToolChoiceTests : SerializationTest +{ + private readonly string _testToolName = "tool"; + private string GetTestJson(string name) => @$"{{ + ""name"": ""{name}"", + ""type"": ""tool"" + }}"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetTypeToTool() + { + var expectedType = "tool"; + + var actual = new SpecificToolChoice(_testToolName); + + actual.Name.Should().Be(_testToolName); + actual.Type.Should().Be(expectedType); + } + + [Fact] + public void Constructor_WhenCalledWithNull_ItShouldThrowArgumentNullException() + { + var action = () => new SpecificToolChoice(null!); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var expected = GetTestJson(_testToolName); + + var choice = new SpecificToolChoice(_testToolName); + + var actual = Serialize(choice); + + JsonAssert.Equal(expected, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new SpecificToolChoice(_testToolName); + + var actual = Deserialize(GetTestJson(_testToolName)); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/StopReasonTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/StopReasonTypeTests.cs new file mode 100644 index 0000000..824be78 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/StopReasonTypeTests.cs @@ -0,0 +1,44 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class StopReasonTypeTests +{ + [Fact] + public void EndTurn_WhenCalled_ItShouldReturnEndTurn() + { + var expected = "end_turn"; + + var actual = StopReasonType.EndTurn; + + actual.Should().Be(expected); + } + + [Fact] + public void MaxTokens_WhenCalled_ItShouldReturnMaxTokens() + { + var expected = "max_tokens"; + + var actual = StopReasonType.MaxTokens; + + actual.Should().Be(expected); + } + + [Fact] + public void StopSequence_WhenCalled_ItShouldReturnStopSequence() + { + var expected = "stop_sequence"; + + var actual = StopReasonType.StopSequence; + + actual.Should().Be(expected); + } + + [Fact] + public void ToolUse_WhenCalled_ItShouldReturnToolUse() + { + var expected = "tool_use"; + + var actual = StopReasonType.ToolUse; + + actual.Should().Be(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/TextContentTests.cs b/tests/AnthropicClient.Tests/Unit/Models/TextContentTests.cs new file mode 100644 index 0000000..4c54c0a --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/TextContentTests.cs @@ -0,0 +1,44 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class TextContentTests : SerializationTest +{ + private readonly string _testJson = @"{ ""text"": ""text"", ""type"": ""text"" }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeText() + { + var expectedText = "text"; + + var result = new TextContent(expectedText); + + result.Text.Should().Be(expectedText); + } + + [Fact] + public void Constructor_WhenCalledAndTextIsNull_ItShouldThrowArgumentNullException() + { + var action = () => new TextContent(null!); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var content = new TextContent("text"); + + var actual = Serialize(content); + + JsonAssert.Equal(_testJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var expected = new TextContent("text"); + + var actual = Deserialize(_testJson); + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ToolChoiceTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ToolChoiceTypeTests.cs new file mode 100644 index 0000000..9e9e63f --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ToolChoiceTypeTests.cs @@ -0,0 +1,46 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ToolChoiceTypeTests +{ + [Fact] + public void Auto_WhenCalled_ItShouldReturnAuto() + { + var expected = "auto"; + + var actual = ToolChoiceType.Auto; + + actual.Should().Be(expected); + } + + [Fact] + public void Any_WhenCalled_ItShouldReturnAny() + { + var expected = "any"; + + var actual = ToolChoiceType.Any; + + Assert.Equal(expected, actual); + } + + [Fact] + public void Tool_WhenCalled_ItShouldReturnTool() + { + var expected = "tool"; + + var actual = ToolChoiceType.Tool; + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("auto", true)] + [InlineData("any", true)] + [InlineData("tool", true)] + [InlineData("invalid", false)] + public void IsValidType_WhenCalledWithType_ItShouldReturnExpectedResult(string type, bool expected) + { + var actual = ToolChoiceType.IsValidType(type); + + Assert.Equal(expected, actual); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ToolResultContentTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ToolResultContentTests.cs new file mode 100644 index 0000000..0761f00 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ToolResultContentTests.cs @@ -0,0 +1,79 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ToolResultContentTests : SerializationTest +{ + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var toolUseId = Guid.NewGuid().ToString(); + var content = "content"; + + var actual = new ToolResultContent(toolUseId, content); + + actual.ToolUseId.Should().Be(toolUseId); + actual.Content.Should().Be(content); + actual.Type.Should().Be("tool_result"); + } + + [Fact] + public void Constructor_WhenCalledAndToolUseIdIsNull_ItShouldThrowArgumentNullException() + { + var content = "content"; + + var action = () => new ToolResultContent(null!, content); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndContentIsNull_ItShouldThrowArgumentNullException() + { + var toolUseId = Guid.NewGuid().ToString(); + + var action = () => new ToolResultContent(toolUseId, null!); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldReturnJsonString() + { + var toolUseId = Guid.NewGuid().ToString(); + var content = "content"; + + var expectedJson = @$"{{ + ""tool_use_id"": ""{toolUseId}"", + ""content"": ""{content}"", + ""type"": ""tool_result"" + }}"; + + var toolResultContent = new ToolResultContent + { + ToolUseId = toolUseId, + Content = content + }; + + var actual = Serialize(toolResultContent); + + JsonAssert.Equal(expectedJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldReturnToolResultContent() + { + var toolUseId = Guid.NewGuid().ToString(); + var content = "content"; + + var json = @$"{{ + ""tool_use_id"": ""{toolUseId}"", + ""content"": ""{content}"", + ""type"": ""tool_result"" + }}"; + + var actual = Deserialize(json); + + actual!.ToolUseId.Should().Be(toolUseId); + actual.Content.Should().Be(content); + actual.Type.Should().Be("tool_result"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ToolTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ToolTests.cs new file mode 100644 index 0000000..7e717d5 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ToolTests.cs @@ -0,0 +1,88 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ToolTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""name"": ""test-name"", + ""description"": ""test-description"", + ""input_schema"": { ""type"": ""object"", ""properties"": {}, ""required"": [] } + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var name = "test-name"; + var description = "test-description"; + var inputSchema = new InputSchema(); + + var tool = new Tool( + name: name, + description: description, + inputSchema: inputSchema + ); + + tool.Name.Should().Be(name); + tool.Description.Should().Be(description); + tool.InputSchema.Should().Be(inputSchema); + } + + [Fact] + public void Constructor_WhenCalledAndNameIsNull_ItShouldThrowArgumentNullException() + { + var action = () => new Tool( + name: null!, + description: "test-description", + inputSchema: new() + ); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndDescriptionIsNull_ItShouldThrowArgumentNullException() + { + var action = () => new Tool( + name: "test-name", + description: null!, + inputSchema: new() + ); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledAndInputSchemaIsNull_ItShouldThrowArgumentNullException() + { + var action = () => new Tool( + name: "test-name", + description: "test-description", + inputSchema: null! + ); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var tool = new Tool( + name: "test-name", + description: "test-description", + inputSchema: new() + ); + + var json = Serialize(tool); + + JsonAssert.Equal(_testJson, json); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var tool = Deserialize(_testJson); + + tool!.Name.Should().Be("test-name"); + tool.Description.Should().Be("test-description"); + tool.InputSchema.Should().BeEquivalentTo(new InputSchema()); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ToolUseContentTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ToolUseContentTests.cs new file mode 100644 index 0000000..56e55a4 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ToolUseContentTests.cs @@ -0,0 +1,83 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ToolUseContentTests : SerializationTest +{ + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeProperties() + { + var id = Guid.NewGuid().ToString(); + var name = "name"; + var input = new Dictionary { { "name", "input" } }; + + var actual = new ToolUseContent() + { + Id = id, + Name = name, + Input = input + }; + + actual.Id.Should().Be(id); + actual.Name.Should().Be(name); + actual.Input.Should().BeEquivalentTo(input); + actual.Type.Should().Be("tool_use"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldReturnJsonString() + { + var id = Guid.NewGuid().ToString(); + var name = "name"; + var input = new Dictionary { { "name", "input" } }; + + var expectedJson = @$"{{ + ""id"": ""{id}"", + ""name"": ""{name}"", + ""input"": {{ ""name"": ""input"" }}, + ""type"": ""tool_use"" + }}"; + + var toolUseContent = new ToolUseContent() + { + Id = id, + Name = name, + Input = input + }; + + var actual = Serialize(toolUseContent); + + JsonAssert.Equal(expectedJson, actual); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldReturnToolUseContent() + { + var id = Guid.NewGuid().ToString(); + var name = "name"; + var input = new Dictionary { { "name", "input" } }; + + var json = @$"{{ + ""id"": ""{id}"", + ""name"": ""{name}"", + ""input"": {{ ""name"": ""input"" }}, + ""type"": ""tool_use"" + }}"; + + var expected = new ToolUseContent() + { + Id = id, + Name = name, + Input = input + }; + + var actual = Deserialize(json); + + actual!.Id.Should().Be(expected.Id); + actual.Name.Should().Be(expected.Name); + + var actualInput = actual.Input.GetValueOrDefault("name")!.ToString(); + var expectedInput = expected.Input.GetValueOrDefault("name")!.ToString(); + actualInput.Should().Be(expectedInput); + + actual.Type.Should().Be(expected.Type); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/SerializationTest.cs b/tests/AnthropicClient.Tests/Unit/SerializationTest.cs new file mode 100644 index 0000000..06dd487 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/SerializationTest.cs @@ -0,0 +1,12 @@ +using AnthropicClient.Json; + +namespace AnthropicClient.Tests.Unit; + +public class SerializationTest +{ + private readonly JsonSerializerOptions _jsonSerializerOptions = JsonSerializationOptions.DefaultOptions; + + protected string Serialize(T obj) => JsonSerializer.Serialize(obj, _jsonSerializerOptions); + + protected T? Deserialize(string json) => JsonSerializer.Deserialize(json, _jsonSerializerOptions); +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Utils/ArgumentValidatorTests.cs b/tests/AnthropicClient.Tests/Unit/Utils/ArgumentValidatorTests.cs new file mode 100644 index 0000000..bb037c7 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Utils/ArgumentValidatorTests.cs @@ -0,0 +1,24 @@ +namespace AnthropicClient.Tests.Unit.Utils; + +public class ArgumentValidatorTests +{ + [Fact] + public void ThrowIfNull_WhenValueIsNull_ItShouldThrowArgumentNullException() + { + object? value = null; + + var action = () => ArgumentValidator.ThrowIfNull(value, "value"); + + action.Should().Throw(); + } + + [Fact] + public void ThrowIfNull_WhenValueIsNotNull_ItShouldNotThrow() + { + var value = new object(); + + var action = () => ArgumentValidator.ThrowIfNull(value, "value"); + + action.Should().NotThrow(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Usings.cs b/tests/AnthropicClient.Tests/Usings.cs new file mode 100644 index 0000000..cc90782 --- /dev/null +++ b/tests/AnthropicClient.Tests/Usings.cs @@ -0,0 +1,9 @@ +global using FluentAssertions; + +global using AnthropicClient.Models; +global using AnthropicClient.Utils; + +global using System.Text.Json; +global using System.Text.Json.JsonDiffPatch.Xunit; + +global using Microsoft.Extensions.Configuration; \ No newline at end of file