diff --git a/.editorconfig b/.editorconfig index 60b870e..54b23c8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,6 +24,10 @@ insert_final_newline = false #### .NET Coding Conventions #### [*.{cs,vb}] +# diagnostics +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.CA1707.severity = none + # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true @@ -77,10 +81,13 @@ dotnet_remove_unnecessary_suppression_exclusions = none #### C# Coding Conventions #### [*.cs] +# namespace preferences +csharp_style_namespace_declarations = file_scoped:suggestion + # var preferences -csharp_style_var_elsewhere = false:silent -csharp_style_var_for_built_in_types = false:silent -csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion # Expression-bodied members csharp_style_expression_bodied_accessors = true:silent @@ -118,7 +125,7 @@ csharp_style_pattern_local_over_anonymous_function = true:suggestion csharp_style_prefer_index_operator = true:suggestion csharp_style_prefer_range_operator = true:suggestion csharp_style_throw_expression = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:silent csharp_style_unused_value_expression_statement_preference = discard_variable:silent # 'using' directive preferences diff --git a/.vscode/settings.json b/.vscode/settings.json index 11a990d..b241574 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,5 +17,8 @@ "targetdir", "typeof" ], - "dotnet.unitTests.runSettingsPath": "./tests/AnthropicClient.Tests/.runsettings" + "dotnet.unitTests.runSettingsPath": "./tests/AnthropicClient.Tests/.runsettings", + "search.exclude": { + "**/docs": true, + } } \ No newline at end of file diff --git a/README.md b/README.md index f23653a..04a1744 100644 --- a/README.md +++ b/README.md @@ -972,6 +972,192 @@ foreach (var content in response.Value.Content) } ``` +### Citations + +Anthropic provides a feature called [Citations](https://docs.anthropic.com/en/docs/build-with-claude/citations) that allows Claude to provide citations for information extracted from documents. This feature enables Claude to reference specific parts of the source material when answering questions, making it easier to verify information and understand the context of responses. + +Citations can be enabled for documents and will return references to the specific locations in the source material where information was found. This library provides comprehensive support for citations through strongly-typed models that represent different types of citation locations. + +#### Enabling Citations for Documents + +You can enable citations for documents by setting the `Citations` property on `DocumentContent` instances: + +```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var request = new MessageRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [ + new DocumentContent(new TextSource("The grass is green. The sky is blue.")) + { + Title = "My Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What color is the grass and sky?") + ]) + ] +); + +var response = await client.CreateMessageAsync(request); + +if (response.IsSuccess is false) +{ + Console.WriteLine("Failed to create message"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +foreach (var content in response.Value.Content) +{ + switch (content) + { + case TextContent textContent: + Console.WriteLine("Response: {0}", textContent.Text); + + if (textContent.Citations is not null) + { + Console.WriteLine("Citations:"); + foreach (var citation in textContent.Citations) + { + Console.WriteLine(" - Cited Text: {0}", citation.CitedText); + Console.WriteLine(" Document: {0}", citation.DocumentTitle); + Console.WriteLine(" Type: {0}", citation.Type); + + switch (citation) + { + case CharacterLocationCitation charCitation: + Console.WriteLine( + " Character Range: {0}-{1}", + charCitation.StartCharIndex, charCitation.EndCharIndex + ); + break; + case PageLocationCitation pageCitation: + Console.WriteLine( + " Page Range: {0}-{1}", + pageCitation.StartPageNumber, pageCitation.EndPageNumber + ); + break; + case ContentBlockLocationCitation blockCitation: + Console.WriteLine( + " Block Range: {0}-{1}", + blockCitation.StartBlockIndex, blockCitation.EndBlockIndex + ); + break; + } + } + } + break; + } +} +``` + +#### Citations with PDF Documents + +Citations work particularly well with PDF documents, providing page-level references: + +```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var pdfBytes = await File.ReadAllBytesAsync("document.pdf"); +var base64Data = Convert.ToBase64String(pdfBytes); + +var request = new MessageRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [ + new DocumentContent("application/pdf", base64Data) + { + Title = "Research Paper", + Citations = new() { Enabled = true } + }, + new TextContent("Summarize the key findings from this research paper.") + ]) + ] +); + +var response = await client.CreateMessageAsync(request); + +if (response.IsSuccess is false) +{ + Console.WriteLine("Failed to create message"); + Console.WriteLine("Error Type: {0}", response.Error.Error.Type); + Console.WriteLine("Error Message: {0}", response.Error.Error.Message); + return; +} + +foreach (var content in response.Value.Content) +{ + switch (content) + { + case TextContent textContent: + Console.WriteLine("Summary: {0}", textContent.Text); + + if (textContent.Citations is not null) + { + Console.WriteLine("\nCitations:"); + foreach (var citation in textContent.Citations.OfType()) + { + Console.WriteLine( + " - \"{0}\" (Pages {1}-{2})", + citation.CitedText, + citation.StartPageNumber, + citation.EndPageNumber + ); + } + } + break; + } +} +``` + +#### Citations in Streaming Responses + +Citations are also supported in streaming responses through the `CitationDelta` events: + +```csharp +using AnthropicClient; +using AnthropicClient.Models; + +var request = new StreamMessageRequest( + model: AnthropicModels.Claude35Sonnet, + messages: [ + new(MessageRole.User, [ + new DocumentContent(new TextSource("The grass is green. The sky is blue.")) + { + Citations = new() { Enabled = true } + }, + new TextContent("What color is the grass?") + ]) + ] +); + +var events = client.CreateMessageAsync(request); + +await foreach (var e in events) +{ + switch (e.Data) + { + case ContentDeltaEventData contentData: + switch (contentData.Delta) + { + case CitationDelta citationDelta: + Console.WriteLine("Citation: {0}", citationDelta.Citation.CitedText); + Console.WriteLine("Type: {0}", citationDelta.Citation.Type); + break; + case TextDelta textDelta: + Console.Write(textDelta.Text); + break; + } + break; + } +} +``` + ### Message Batches Anthropic provides a feature called [Message Batches](https://docs.anthropic.com/en/docs/build-with-claude/message-batches) that allows you to send multiple messages in a single request. This feature is covered in depth in [Anthropic's API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/message-batches). diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index f05f3e6..2e78ac2 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -124,10 +124,37 @@ public async IAsyncEnumerable CreateMessageAsync(StreamMessageRe // current content type and delta type if (currentEvent.Type is EventType.ContentBlockDelta && currentEvent.Data is ContentDeltaEventData contentDeltaData) { - if (content is TextContent textContent && contentDeltaData.Delta is TextDelta textDelta) + if (content is TextContent textContent) { - var newText = textContent.Text + textDelta.Text; - content = new TextContent(newText); + if (contentDeltaData.Delta is TextDelta textDelta) + { + var newText = textContent.Text + textDelta.Text; + + content = new TextContent(newText) + { + Citations = textContent.Citations, + }; + } + + if (contentDeltaData.Delta is CitationDelta citationDelta) + { + var citations = new List() + { + citationDelta.Citation, + }; + + if (textContent.Citations is not null) + { + citations.AddRange(textContent.Citations); + } + + var newContent = new TextContent(textContent.Text) + { + Citations = [.. citations], + }; + + content = newContent; + } } if (content is ToolUseContent toolUseContent && contentDeltaData.Delta is JsonDelta jsonDelta) diff --git a/src/AnthropicClient/Json/CitationConverter.cs b/src/AnthropicClient/Json/CitationConverter.cs new file mode 100644 index 0000000..baa6d9b --- /dev/null +++ b/src/AnthropicClient/Json/CitationConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AnthropicClient.Models; + +namespace AnthropicClient.Json; + +class CitationConverter : JsonConverter +{ + public override Citation 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 + { + CitationType.CharacterLocation => JsonSerializer.Deserialize(root.GetRawText(), options)!, + CitationType.PageLocation => JsonSerializer.Deserialize(root.GetRawText(), options)!, + CitationType.ContentBlockLocation => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown citation type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, Citation value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Json/ContentDeltaConverter.cs b/src/AnthropicClient/Json/ContentDeltaConverter.cs index af2dbb8..28736fe 100644 --- a/src/AnthropicClient/Json/ContentDeltaConverter.cs +++ b/src/AnthropicClient/Json/ContentDeltaConverter.cs @@ -16,6 +16,7 @@ public override ContentDelta Read(ref Utf8JsonReader reader, Type typeToConvert, { ContentDeltaType.TextDelta => JsonSerializer.Deserialize(root.GetRawText(), options)!, ContentDeltaType.JsonDelta => JsonSerializer.Deserialize(root.GetRawText(), options)!, + ContentDeltaType.CitationDelta => JsonSerializer.Deserialize(root.GetRawText(), options)!, _ => throw new JsonException($"Unknown content type: {type}") }; } diff --git a/src/AnthropicClient/Json/JsonSerializationOptions.cs b/src/AnthropicClient/Json/JsonSerializationOptions.cs index c1d5c40..355aae8 100644 --- a/src/AnthropicClient/Json/JsonSerializationOptions.cs +++ b/src/AnthropicClient/Json/JsonSerializationOptions.cs @@ -18,6 +18,8 @@ static class JsonSerializationOptions new ContentDeltaConverter(), new JsonStringEnumConverter(), new MessageBatchResultConverter(), + new CitationConverter(), + new SourceConverter(), }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; diff --git a/src/AnthropicClient/Json/SourceConverter.cs b/src/AnthropicClient/Json/SourceConverter.cs new file mode 100644 index 0000000..a68fd70 --- /dev/null +++ b/src/AnthropicClient/Json/SourceConverter.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AnthropicClient.Models; + +namespace AnthropicClient.Json; + +class SourceConverter : JsonConverter +{ + public override Source 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 + { + SourceType.Text => JsonSerializer.Deserialize(root.GetRawText(), options)!, + SourceType.Content => JsonSerializer.Deserialize(root.GetRawText(), options)!, + SourceType.Base64 => DeserializeBase64Source(root, options), + _ => throw new JsonException($"Unknown source type: {type}") + }; + } + + private static Source DeserializeBase64Source(JsonElement root, JsonSerializerOptions options) + { + var mediaType = root.TryGetProperty("media_type", out var mediaTypeElement) + ? mediaTypeElement.GetString() ?? throw new JsonException("Missing 'media_type' property") + : throw new JsonException("Missing 'media_type' property"); + + var isImage = ImageType.IsValidImageType(mediaType); + + return isImage + ? JsonSerializer.Deserialize(root.GetRawText(), options)! + : JsonSerializer.Deserialize(root.GetRawText(), options)!; + } + + public override void Write(Utf8JsonWriter writer, Source value, JsonSerializerOptions options) + { + if (value is TextSource textSource) + { + JsonSerializer.Serialize(writer, textSource, options); + return; + } + + if (value is CustomSource customSource) + { + JsonSerializer.Serialize(writer, customSource, options); + return; + } + + if (value is Base64Source base64Source) + { + JsonSerializer.Serialize(writer, base64Source, options); + return; + } + + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/Base64Source.cs b/src/AnthropicClient/Models/Base64Source.cs new file mode 100644 index 0000000..6a5f597 --- /dev/null +++ b/src/AnthropicClient/Models/Base64Source.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a base64 source. +/// +public class Base64Source : Source +{ + /// + /// Gets the media type of the source. + /// + [JsonPropertyName("media_type")] + public string MediaType { get; init; } = string.Empty; + + /// + /// Gets the data of the source. + /// + public string Data { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The media type of the source. + /// The data of the source. + /// Thrown when the media type is invalid. + /// Thrown when the media type or data is null. + /// A new instance of the class. + public Base64Source(string mediaType, string data) : base(SourceType.Base64) + { + ArgumentValidator.ThrowIfNull(mediaType, nameof(mediaType)); + ArgumentValidator.ThrowIfNull(data, nameof(data)); + + MediaType = mediaType; + Data = data; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CharacterLocationCitation.cs b/src/AnthropicClient/Models/CharacterLocationCitation.cs new file mode 100644 index 0000000..0323d07 --- /dev/null +++ b/src/AnthropicClient/Models/CharacterLocationCitation.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a citation for specific locations within text content. +/// +public class CharacterLocationCitation : Citation +{ + /// + /// Gets the start character index of the citation. + /// + [JsonPropertyName("start_char_index")] + public int StartCharIndex { get; init; } + + /// + /// Gets the end character index of the citation. + /// + [JsonPropertyName("end_char_index")] + public int EndCharIndex { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// A new instance of . + public CharacterLocationCitation() : base(CitationType.CharacterLocation) + { + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/Citation.cs b/src/AnthropicClient/Models/Citation.cs new file mode 100644 index 0000000..e502e5b --- /dev/null +++ b/src/AnthropicClient/Models/Citation.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a citation +/// +public abstract class Citation +{ + /// + /// Gets the type of the citation. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Gets the text that is cited. + /// + [JsonPropertyName("cited_text")] + public string CitedText { get; init; } = string.Empty; + + /// + /// Gets the document index of the citation. + /// + [JsonPropertyName("document_index")] + public int DocumentIndex { get; init; } + + /// + /// Gets the title of the document from which the citation is made. + /// + [JsonPropertyName("document_title")] + public string DocumentTitle { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class with a specified type. + /// + /// The type of the citation. + /// A new instance of . + protected Citation(string type) + { + Type = type; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CitationDelta.cs b/src/AnthropicClient/Models/CitationDelta.cs new file mode 100644 index 0000000..e299d97 --- /dev/null +++ b/src/AnthropicClient/Models/CitationDelta.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a citation delta. +/// +public class CitationDelta : ContentDelta +{ + /// + /// Gets the citation associated with this delta. + /// + public Citation Citation { get; init; } = new CharacterLocationCitation(); + + [JsonConstructor] + internal CitationDelta() : base(ContentDeltaType.CitationDelta) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The citation to associate with this delta. + /// Thrown when is null. + /// A new instance of . + public CitationDelta(Citation citation) : base(ContentDeltaType.CitationDelta) + { + ArgumentValidator.ThrowIfNull(citation, nameof(citation)); + Citation = citation; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CitationOption.cs b/src/AnthropicClient/Models/CitationOption.cs new file mode 100644 index 0000000..8917b5a --- /dev/null +++ b/src/AnthropicClient/Models/CitationOption.cs @@ -0,0 +1,12 @@ +namespace AnthropicClient.Models; + +/// +/// Represents whether citations are enabled for a document. +/// +public class CitationOption +{ + /// + /// Gets a value indicating whether citations are enabled for the document. + /// + public bool Enabled { get; init; } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CitationType.cs b/src/AnthropicClient/Models/CitationType.cs new file mode 100644 index 0000000..d0b5536 --- /dev/null +++ b/src/AnthropicClient/Models/CitationType.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Models; + +/// +/// The types of citations that can be returned by the Anthropic API. +/// +public static class CitationType +{ + /// + /// A citation that refers to a specific character in the text. + /// + public const string CharacterLocation = "char_location"; + + /// + /// A citation that refers to a specific page in the text. + /// + public const string PageLocation = "page_location"; + + /// + /// A citation that refers to a specific section in the text. + /// + public const string ContentBlockLocation = "content_block_location"; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ContentBlockLocationCitation.cs b/src/AnthropicClient/Models/ContentBlockLocationCitation.cs new file mode 100644 index 0000000..701af71 --- /dev/null +++ b/src/AnthropicClient/Models/ContentBlockLocationCitation.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a citation for content blocks within custom content. +/// +public class ContentBlockLocationCitation : Citation +{ + /// + /// Gets the start block index of the citation. + /// + [JsonPropertyName("start_block_index")] + public int StartBlockIndex { get; init; } + + /// + /// Gets the end block index of the citation. + /// + [JsonPropertyName("end_block_index")] + public int EndBlockIndex { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// A new instance of . + public ContentBlockLocationCitation() : base(CitationType.ContentBlockLocation) + { + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/ContentDeltaType.cs b/src/AnthropicClient/Models/ContentDeltaType.cs index 00c86a7..ce179c4 100644 --- a/src/AnthropicClient/Models/ContentDeltaType.cs +++ b/src/AnthropicClient/Models/ContentDeltaType.cs @@ -14,4 +14,9 @@ public static class ContentDeltaType /// The input_json_delta. /// public const string JsonDelta = "input_json_delta"; + + /// + /// The citation_delta. + /// + public const string CitationDelta = "citations_delta"; } \ No newline at end of file diff --git a/src/AnthropicClient/Models/CustomSource.cs b/src/AnthropicClient/Models/CustomSource.cs new file mode 100644 index 0000000..70dabb5 --- /dev/null +++ b/src/AnthropicClient/Models/CustomSource.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents a custom source that contains a list of text content. +/// +public class CustomSource : Source +{ + /// + /// Gets the list of text content that makes up the custom source. + /// + public List Content { get; init; } = []; + + [JsonConstructor] + internal CustomSource() : base(SourceType.Content) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// A new instance of the class. + /// Thrown when the content is null. + public CustomSource(List content) : base(SourceType.Content) + { + ArgumentValidator.ThrowIfNull(content, nameof(content)); + + Content = content; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/DocumentContent.cs b/src/AnthropicClient/Models/DocumentContent.cs index 1389d84..3f989a0 100644 --- a/src/AnthropicClient/Models/DocumentContent.cs +++ b/src/AnthropicClient/Models/DocumentContent.cs @@ -12,7 +12,22 @@ public class DocumentContent : Content /// /// Gets the source of the document. /// - public DocumentSource Source { get; init; } = new(); + public Source Source { get; init; } = new DocumentSource(); + + /// + /// Gets the title of the document. + /// + public string? Title { get; init; } + + /// + /// Gets the context of the document. + /// + public string? Context { get; init; } + + /// + /// Gets whether citations are enabled for the document. + /// + public CitationOption? Citations { get; init; } [JsonConstructor] internal DocumentContent() @@ -36,7 +51,7 @@ public DocumentContent(string mediaType, string data) : base(ContentType.Documen { Validate(mediaType, data); - Source = new(mediaType, data); + Source = new DocumentSource(mediaType, data); } /// @@ -51,6 +66,33 @@ public DocumentContent(string mediaType, string data, CacheControl cacheControl) { Validate(mediaType, data); - Source = new(mediaType, data); + Source = new DocumentSource(mediaType, data); + } + + /// + /// Initializes a new instance of the class with a document source. + /// + /// The document source. + /// A new instance of the class. + /// Thrown when the source is null. + public DocumentContent(Source source) : base(ContentType.Document) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + + Source = source; + } + + /// + /// Initializes a new instance of the class with a document source and cache control. + /// + /// The document source. + /// The cache control to be used for the content. + /// A new instance of the class. + /// Thrown when the source is null. + public DocumentContent(Source source, CacheControl cacheControl) : base(ContentType.Document, cacheControl) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + + Source = source; } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/DocumentSource.cs b/src/AnthropicClient/Models/DocumentSource.cs index e18f978..e7029f2 100644 --- a/src/AnthropicClient/Models/DocumentSource.cs +++ b/src/AnthropicClient/Models/DocumentSource.cs @@ -7,26 +7,10 @@ namespace AnthropicClient.Models; /// /// Represents a document source. /// -public class DocumentSource +public class DocumentSource : Base64Source { - /// - /// Gets the media type of the document. - /// - [JsonPropertyName("media_type")] - public string MediaType { get; init; } = string.Empty; - - /// - /// Gets the data of the document. - /// - public string Data { get; init; } = string.Empty; - - /// - /// Gets the type of encoding of the document data. - /// - public string Type { get; init; } = "base64"; - [JsonConstructor] - internal DocumentSource() + internal DocumentSource() : base(string.Empty, string.Empty) { } @@ -38,12 +22,7 @@ internal DocumentSource() /// Thrown when the media type is invalid. /// Thrown when the media type or data is null. /// A new instance of the class. - public DocumentSource(string mediaType, string data) + public DocumentSource(string mediaType, string data) : base(mediaType, data) { - ArgumentValidator.ThrowIfNull(mediaType, nameof(mediaType)); - ArgumentValidator.ThrowIfNull(data, nameof(data)); - - MediaType = mediaType; - Data = data; } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/ImageContent.cs b/src/AnthropicClient/Models/ImageContent.cs index b096fab..1d4a949 100644 --- a/src/AnthropicClient/Models/ImageContent.cs +++ b/src/AnthropicClient/Models/ImageContent.cs @@ -12,7 +12,7 @@ public class ImageContent : Content /// /// Gets the source of the image. /// - public ImageSource Source { get; init; } = new(); + public Source Source { get; init; } = new ImageSource(); [JsonConstructor] internal ImageContent() @@ -36,7 +36,7 @@ public ImageContent(string mediaType, string data) : base(ContentType.Image) { Validate(mediaType, data); - Source = new(mediaType, data); + Source = new ImageSource(mediaType, data); } /// @@ -51,6 +51,6 @@ public ImageContent(string mediaType, string data, CacheControl cacheControl) : { Validate(mediaType, data); - Source = new(mediaType, data); + Source = new ImageSource(mediaType, data); } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/ImageSource.cs b/src/AnthropicClient/Models/ImageSource.cs index cc68c74..35257c5 100644 --- a/src/AnthropicClient/Models/ImageSource.cs +++ b/src/AnthropicClient/Models/ImageSource.cs @@ -7,26 +7,10 @@ namespace AnthropicClient.Models; /// /// Represents an image source. /// -public class ImageSource +public class ImageSource : Base64Source { - /// - /// 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; - - /// - /// Gets the type of encoding of the image data. - /// - public string Type { get; init; } = "base64"; - [JsonConstructor] - internal ImageSource() + internal ImageSource() : base(string.Empty, string.Empty) { } @@ -38,17 +22,13 @@ internal ImageSource() /// 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) + public ImageSource(string mediaType, string data) : base(mediaType, 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; } diff --git a/src/AnthropicClient/Models/PageLocationCitation.cs b/src/AnthropicClient/Models/PageLocationCitation.cs new file mode 100644 index 0000000..1283e9b --- /dev/null +++ b/src/AnthropicClient/Models/PageLocationCitation.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a citation for text within a page of a document. +/// +public class PageLocationCitation : Citation +{ + /// + /// Gets the start page number of the citation. + /// + [JsonPropertyName("start_page_number")] + public int StartPageNumber { get; init; } + + /// + /// Gets the end page number of the citation. + /// + [JsonPropertyName("end_page_number")] + public int EndPageNumber { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// A new instance of . + public PageLocationCitation() : base(CitationType.PageLocation) + { + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/Source.cs b/src/AnthropicClient/Models/Source.cs new file mode 100644 index 0000000..9f0b153 --- /dev/null +++ b/src/AnthropicClient/Models/Source.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a base class for sources. +/// +public abstract class Source +{ + /// + /// Gets the type of the source. + /// + public string Type { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// The type of the source. + /// A new instance of the class. + protected Source(string type) + { + Type = type; + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/SourceType.cs b/src/AnthropicClient/Models/SourceType.cs new file mode 100644 index 0000000..7079bbe --- /dev/null +++ b/src/AnthropicClient/Models/SourceType.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Models; + +/// +/// Represents the types of document sources that can be used in the Anthropic API. +/// +public static class SourceType +{ + /// + /// The base64 encoded document source type. + /// + public const string Base64 = "base64"; + + /// + /// The custom content document source type. + /// + public const string Content = "content"; + + /// + /// The text document source type. + /// + public const string Text = "text"; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextContent.cs b/src/AnthropicClient/Models/TextContent.cs index a6751cf..5320567 100644 --- a/src/AnthropicClient/Models/TextContent.cs +++ b/src/AnthropicClient/Models/TextContent.cs @@ -14,6 +14,11 @@ public class TextContent : Content /// public string Text { get; init; } = string.Empty; + /// + /// Gets the citations associated with the text content. + /// + public Citation[]? Citations { get; init; } + [JsonConstructor] internal TextContent() : base(ContentType.Text) { diff --git a/src/AnthropicClient/Models/TextSource.cs b/src/AnthropicClient/Models/TextSource.cs new file mode 100644 index 0000000..e220a13 --- /dev/null +++ b/src/AnthropicClient/Models/TextSource.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace AnthropicClient.Models; + +/// +/// Represents a text document source. +/// +public class TextSource : Source +{ + /// + /// Gets the media type of the source. + /// + [JsonPropertyName("media_type")] + public string MediaType { get; } = "text/plain"; + + /// + /// Gets the data of the source. + /// + public string Data { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The data of the document. + /// Thrown when the data is null. + /// A new instance of the class. + public TextSource(string data) : base(SourceType.Text) + { + Data = data; + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Data/EventTestData.cs b/tests/AnthropicClient.Tests/Data/EventTestData.cs index 6fe6a87..ea08c7f 100644 --- a/tests/AnthropicClient.Tests/Data/EventTestData.cs +++ b/tests/AnthropicClient.Tests/Data/EventTestData.cs @@ -24,7 +24,13 @@ public static MemoryStream GetEventStream() Usage = new Usage { InputTokens = 472, OutputTokens = 91 }, StopReason = "tool_use", Content = [ - new TextContent("Okay, let's check the weather for San Francisco, CA:"), + new TextContent("Okay, let's check the weather for San Francisco, CA:") + { + Citations = [ + new CharacterLocationCitation(), + new CharacterLocationCitation(), + ] + }, new ToolUseContent() { Id = "toolu_01T1x1fJ34qAmk2tNTrN7Up6", @@ -380,6 +386,46 @@ public IEnumerator GetEnumerator() }, }; + yield return new object[] + { + """ + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"citations_delta","citation": {"type":"char_location","start_char_index":0,"end_char_index":0, "cited_text":"","document_index":0,"document_title":""}}} + """, + new AnthropicEvent() + { + Type = EventType.ContentBlockDelta, + Data = new ContentDeltaEventData() + { + Index = 0, + Delta = new CitationDelta() + { + Citation = new CharacterLocationCitation() + } + }, + }, + }; + + yield return new object[] + { + """ + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"citations_delta","citation":{"type":"char_location","start_char_index":0,"end_char_index":0,"cited_text":"","document_index":0,"document_title":""}}} + """, + new AnthropicEvent() + { + Type = EventType.ContentBlockDelta, + Data = new ContentDeltaEventData() + { + Index = 0, + Delta = new CitationDelta() + { + Citation = new CharacterLocationCitation() + }, + }, + }, + }; + yield return new object[] { """ diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 8fa81f6..cb1b9fe 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -83,7 +83,7 @@ public async Task CreateMessageAsync_WhenImageIsSent_ItShouldReturnResponse() result.Value.Should().BeOfType(); result.Value.Content.Should().NotBeNullOrEmpty(); - var text = result.Value.Content.Aggregate("", (acc, content) => + var text = result.Value.Content.Aggregate("", static (acc, content) => { if (content is TextContent textContent) { @@ -122,7 +122,7 @@ public async Task CreateMessageAsync_WhenSystemMessagesContainCacheControl_ItSho resultOne.IsSuccess.Should().BeTrue(); resultOne.Value.Should().BeOfType(); resultOne.Value.Content.Should().NotBeNullOrEmpty(); - resultOne.Value.Usage.Should().Match(u => u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0); + resultOne.Value.Usage.Should().Match(static u => u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0); request.Messages.Add(new(MessageRole.Assistant, resultOne.Value.Content)); request.Messages.Add(new(MessageRole.User, [new TextContent("What is the main theme of this story?")])); @@ -158,7 +158,7 @@ public async Task CreateMessageAsync_WhenMessagesContainCacheControl_ItShouldUse resultOne.IsSuccess.Should().BeTrue(); resultOne.Value.Should().BeOfType(); resultOne.Value.Content.Should().NotBeNullOrEmpty(); - resultOne.Value.Usage.Should().Match(u => u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0); + resultOne.Value.Usage.Should().Match(static u => u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0); request.Messages.Add(new(MessageRole.Assistant, resultOne.Value.Content)); request.Messages.Add(new(MessageRole.User, [new TextContent("What is the main theme of this story?")])); @@ -236,7 +236,7 @@ public async Task CreateMessageAsync_WhenProvidedWithPDF_ItShouldReturnResponse( result.Value.Should().BeOfType(); result.Value.Content.Should().NotBeNullOrEmpty(); - var text = result.Value.Content.Aggregate("", (acc, content) => + var text = result.Value.Content.Aggregate("", static (acc, content) => { if (content is TextContent textContent) { @@ -273,7 +273,7 @@ public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShoul resultOne.IsSuccess.Should().BeTrue(); resultOne.Value.Should().BeOfType(); resultOne.Value.Content.Should().NotBeNullOrEmpty(); - resultOne.Value.Usage.Should().Match(u => u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0); + resultOne.Value.Usage.Should().Match(static u => u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0); request.Messages.Add(new(MessageRole.Assistant, resultOne.Value.Content)); request.Messages.Add(new(MessageRole.User, [new TextContent("What is the main theme of this paper?")])); @@ -286,6 +286,236 @@ public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShoul resultTwo.Value.Usage.CacheReadInputTokens.Should().BeGreaterThan(0); } + [Fact] + public async Task CreateMessageAsync_WhenCitationsAreEnabledForTextDocumentSource_ItShouldReturnCitationsInResponse() + { + var request = new MessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent( + new TextSource("The grass is green. The sky is blue.") + ) + { + Title = "My Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What color is the grass and sky?"), + ] + ) + ] + ); + + var result = await _client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + + var citations = result.Value + .Content + .OfType() + .SelectMany(static c => c.Citations is null ? [] : c.Citations); + + citations.OfType().Should().NotBeEmpty(); + } + + [Fact] + public async Task CreateMessageAsync_WhenCitationsAreEnabledForPDFDocumentSource_ItShouldReturnCitationsInResponse() + { + var pdfPath = TestFileHelper.GetTestFilePath("addendum.pdf"); + var bytes = await File.ReadAllBytesAsync(pdfPath); + var base64Data = Convert.ToBase64String(bytes); + + var request = new MessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent("application/pdf", base64Data) + { + Title = "My PDF Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What is the title of this paper?"), + ] + ) + ] + ); + + var result = await _client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + + var citations = result.Value + .Content + .OfType() + .SelectMany(static c => c.Citations is null ? [] : c.Citations); + + citations.OfType().Should().NotBeEmpty(); + } + + [Fact] + public async Task CreateMessageAsync_WhenCitationsAreEnabledForCustomDocumentSource_ItShouldReturnCitationsInResponse() + { + var request = new MessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent( + new CustomSource([ + new TextContent("The grass is green. The sky is blue.") + ]) + ) + { + Title = "My Custom Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What color is the grass and sky?"), + ] + ) + ] + ); + + var result = await _client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + + var citations = result.Value + .Content + .OfType() + .SelectMany(static c => c.Citations is null ? [] : c.Citations); + + citations.OfType().Should().NotBeEmpty(); + } + + [Fact] + public async Task CreateMessageAsync_WhenStreamingAndCitationsAreEnabledForTextDocumentSource_ItShouldReturnCitationsInResponse() + { + var request = new StreamMessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent( + new TextSource("The grass is green. The sky is blue.") + ) + { + Title = "My Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What color is the grass and sky?"), + ] + ) + ] + ); + + var result = _client.CreateMessageAsync(request); + + var messageCompleteEvent = await result + .Where(e => e.Type is EventType.MessageComplete) + .FirstAsync(); + + var citations = messageCompleteEvent.Data + .As() + .Message + .Content + .OfType() + .SelectMany(static c => c.Citations is null ? [] : c.Citations); + + citations.OfType().Should().NotBeEmpty(); + } + + [Fact] + public async Task CreateMessageAsync_WhenStreamingAndCitationsAreEnabledForPDFDocumentSource_ItShouldReturnCitationsInResponse() + { + var pdfPath = TestFileHelper.GetTestFilePath("addendum.pdf"); + var bytes = await File.ReadAllBytesAsync(pdfPath); + var base64Data = Convert.ToBase64String(bytes); + + var request = new StreamMessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent("application/pdf", base64Data) + { + Title = "My PDF Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What is the title of this paper?"), + ] + ) + ] + ); + + var result = _client.CreateMessageAsync(request); + + var messageCompleteEvent = await result + .Where(e => e.Type is EventType.MessageComplete) + .FirstAsync(); + + var citations = messageCompleteEvent.Data + .As() + .Message + .Content + .OfType() + .SelectMany(static c => c.Citations is null ? [] : c.Citations); + + citations.OfType().Should().NotBeEmpty(); + } + + [Fact] + public async Task CreateMessageAsync_WhenStreamingAndCitationsAreEnabledForCustomDocumentSource_ItShouldReturnCitationsInResponse() + { + var request = new StreamMessageRequest( + model: AnthropicModels.Claude35HaikuLatest, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent( + new CustomSource([ + new TextContent("The grass is green. The sky is blue.") + ]) + ) + { + Title = "My Custom Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What color is the grass and sky?"), + ] + ) + ] + ); + + var result = _client.CreateMessageAsync(request); + + var messageCompleteEvent = await result + .Where(e => e.Type is EventType.MessageComplete) + .FirstAsync(); + + var citations = messageCompleteEvent.Data + .As() + .Message + .Content + .OfType() + .SelectMany(static c => c.Citations is null ? [] : c.Citations); + + citations.OfType().Should().NotBeEmpty(); + } + [Fact] public async Task CountMessageTokensAsync_WhenCalled_ItShouldReturnResponse() { diff --git a/tests/AnthropicClient.Tests/Unit/Json/CitationConverterTests.cs b/tests/AnthropicClient.Tests/Unit/Json/CitationConverterTests.cs new file mode 100644 index 0000000..2f7fb5f --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Json/CitationConverterTests.cs @@ -0,0 +1,14 @@ +namespace AnthropicClient.Tests.Unit.Json; + +public class CitationConverterTests : SerializationTest +{ + [Fact] + public void JsonDeserialization_WhenTypeIsUnknown_ItThrowException() + { + var json = @"{ ""type"": ""unknown"" }"; + + var action = () => Deserialize(json); + + action.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Json/SourceConverterTests.cs b/tests/AnthropicClient.Tests/Unit/Json/SourceConverterTests.cs new file mode 100644 index 0000000..f62395c --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Json/SourceConverterTests.cs @@ -0,0 +1,31 @@ +namespace AnthropicClient.Tests.Unit.Json; + +public class SourceConverterTests : SerializationTest +{ + [Fact] + public void JsonDeserialization_WhenTypeIsUnknown_ItThrowsException() + { + var json = @"{ ""type"": ""unknown"" }"; + + var action = () => Deserialize(json); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSourceIsNotKnown_ItShouldHaveExpectedShape() + { + var source = new TestSource(); + + var result = Serialize(source); + + JsonAssert.Equal(@"{ ""type"": ""test"" }", result); + } + + private class TestSource : Source + { + public TestSource() : base("test") + { + } + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs new file mode 100644 index 0000000..33761d7 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs @@ -0,0 +1,88 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class Base64SourceTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""base64"", + ""media_type"": ""application/pdf"", + ""data"": ""base64data"" + }"; + + [Fact] + public void Constructor_WhenCalledWithValidArguments_ItShouldSetProperties() + { + var mediaType = "application/pdf"; + var data = "base64data"; + + var source = new Base64Source(mediaType, data); + + source.MediaType.Should().Be(mediaType); + source.Data.Should().Be(data); + source.Type.Should().Be("base64"); + } + + [Fact] + public void Constructor_WhenCalledWithNullMediaType_ItShouldThrowArgumentNullException() + { + string? mediaType = null; + var data = "base64data"; + + var action = () => new Base64Source(mediaType!, data); + + action.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithNullData_ItShouldThrowArgumentNullException() + { + var mediaType = "application/pdf"; + string? data = null; + + var action = () => new Base64Source(mediaType, data!); + + action.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var mediaType = "application/pdf"; + var data = "base64data"; + var source = new Base64Source(mediaType, data); + + var result = Serialize(source); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var source = Deserialize(_testJson); + + var base64Source = source.As(); + base64Source!.Type.Should().Be("base64"); + base64Source.MediaType.Should().Be("application/pdf"); + base64Source.Data.Should().Be("base64data"); + } + + [Fact] + public void JsonDeserialization_WhenMediaTypeIsMissing_ItShouldThrowException() + { + var json = @"{ ""type"": ""base64"", ""data"": ""base64data"" }"; + + var action = () => Deserialize(json); + + action.Should().Throw(); + } + + [Fact] + public void JsonDeserialization_WhenMediaTypeIsNull_ItShouldThrowException() + { + var json = @"{ ""type"": ""base64"", ""media_type"": null, ""data"": ""base64data"" }"; + + var action = () => Deserialize(json); + + action.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs new file mode 100644 index 0000000..397c572 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs @@ -0,0 +1,58 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CharacterLocationCitationTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""char_location"", + ""cited_text"": ""cited text"", + ""document_index"": 1, + ""document_title"": ""document title"", + ""start_char_index"": 2, + ""end_char_index"": 3 + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var result = new CharacterLocationCitation(); + + result.Type.Should().Be("char_location"); + result.CitedText.Should().BeEmpty(); + result.DocumentIndex.Should().Be(0); + result.DocumentTitle.Should().BeEmpty(); + result.StartCharIndex.Should().Be(0); + result.EndCharIndex.Should().Be(0); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var citation = new CharacterLocationCitation + { + Type = "char_location", + CitedText = "cited text", + DocumentIndex = 1, + DocumentTitle = "document title", + StartCharIndex = 2, + EndCharIndex = 3 + }; + + var result = Serialize(citation); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var citation = Deserialize(_testJson); + + var characterLocationCitation = citation.As(); + characterLocationCitation!.Type.Should().Be("char_location"); + characterLocationCitation.CitedText.Should().Be("cited text"); + characterLocationCitation.DocumentIndex.Should().Be(1); + characterLocationCitation.DocumentTitle.Should().Be("document title"); + characterLocationCitation.StartCharIndex.Should().Be(2); + characterLocationCitation.EndCharIndex.Should().Be(3); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/CitationDeltaTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CitationDeltaTests.cs new file mode 100644 index 0000000..2f6d691 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CitationDeltaTests.cs @@ -0,0 +1,78 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CitationDeltaTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""citations_delta"", + ""citation"": { + ""type"": ""content_block_location"", + ""cited_text"": ""cited text"", + ""document_index"": 1, + ""document_title"": ""document title"", + ""start_block_index"": 2, + ""end_block_index"": 3 + } + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var result = new CitationDelta(); + + result.Type.Should().Be("citations_delta"); + result.Citation.Should().BeEquivalentTo(new CharacterLocationCitation()); + } + + [Fact] + public void Constructor_WhenCalledWithCitation_ItShouldSetProperties() + { + var citation = new ContentBlockLocationCitation(); + var result = new CitationDelta(citation); + + result.Type.Should().Be("citations_delta"); + result.Citation.Should().BeSameAs(citation); + } + + [Fact] + public void Constructor_WhenCalledWithNullCitation_ItShouldThrowException() + { + var act = () => new CitationDelta(null!); + + act.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var citation = new ContentBlockLocationCitation + { + Type = "content_block_location", + CitedText = "cited text", + DocumentIndex = 1, + DocumentTitle = "document title", + StartBlockIndex = 2, + EndBlockIndex = 3 + }; + + var citationDelta = new CitationDelta(citation); + var result = Serialize(citationDelta); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var citation = Deserialize(_testJson); + + var citationDelta = citation.As(); + citationDelta!.Type.Should().Be("citations_delta"); + + var contentBlockLocationCitation = citationDelta.Citation.As(); + contentBlockLocationCitation!.CitedText.Should().Be("cited text"); + contentBlockLocationCitation.DocumentIndex.Should().Be(1); + contentBlockLocationCitation.DocumentTitle.Should().Be("document title"); + contentBlockLocationCitation.StartBlockIndex.Should().Be(2); + contentBlockLocationCitation.EndBlockIndex.Should().Be(3); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/CitationOptionTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CitationOptionTests.cs new file mode 100644 index 0000000..36ff5d8 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CitationOptionTests.cs @@ -0,0 +1,12 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CitationOptionTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var result = new CitationOption(); + + result.Enabled.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/CitationTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CitationTypeTests.cs new file mode 100644 index 0000000..c0fb416 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CitationTypeTests.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CitationTypeTests +{ + [Fact] + public void ContentBlockLocation_WhenCalled_ItShouldReturnCorrectValue() + { + CitationType.ContentBlockLocation.Should().Be("content_block_location"); + } + + [Fact] + public void CharacterLocation_WhenCalled_ItShouldReturnCorrectValue() + { + CitationType.CharacterLocation.Should().Be("char_location"); + } + + [Fact] + public void PageLocation_WhenCalled_ItShouldReturnCorrectValue() + { + CitationType.PageLocation.Should().Be("page_location"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs new file mode 100644 index 0000000..8188f0c --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs @@ -0,0 +1,58 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ContentBlockLocationCitationTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""content_block_location"", + ""cited_text"": ""cited text"", + ""document_index"": 1, + ""document_title"": ""document title"", + ""start_block_index"": 2, + ""end_block_index"": 3 + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var result = new ContentBlockLocationCitation(); + + result.Type.Should().Be("content_block_location"); + result.CitedText.Should().BeEmpty(); + result.DocumentIndex.Should().Be(0); + result.DocumentTitle.Should().BeEmpty(); + result.StartBlockIndex.Should().Be(0); + result.EndBlockIndex.Should().Be(0); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var citation = new ContentBlockLocationCitation + { + Type = "content_block_location", + CitedText = "cited text", + DocumentIndex = 1, + DocumentTitle = "document title", + StartBlockIndex = 2, + EndBlockIndex = 3 + }; + + var result = Serialize(citation); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var citation = Deserialize(_testJson); + + var contentBlockLocationCitation = citation.As(); + contentBlockLocationCitation!.Type.Should().Be("content_block_location"); + contentBlockLocationCitation.CitedText.Should().Be("cited text"); + contentBlockLocationCitation.DocumentIndex.Should().Be(1); + contentBlockLocationCitation.DocumentTitle.Should().Be("document title"); + contentBlockLocationCitation.StartBlockIndex.Should().Be(2); + contentBlockLocationCitation.EndBlockIndex.Should().Be(3); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ContentDeltaTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ContentDeltaTypeTests.cs index 32a0023..bb46b42 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/ContentDeltaTypeTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/ContentDeltaTypeTests.cs @@ -21,4 +21,14 @@ public void JsonDelta_WhenCalled_ItShouldReturnExpectedValue() actual.Should().Be(expected); } + + [Fact] + public void CitationDelta_WhenCalled_ItShouldReturnExpectedValue() + { + var expected = "citations_delta"; + + var actual = ContentDeltaType.CitationDelta; + + actual.Should().Be(expected); + } } \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs new file mode 100644 index 0000000..fd7dc0b --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs @@ -0,0 +1,71 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CustomSourceTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""content"", + ""content"": [ + { + ""type"": ""text"", + ""text"": ""Sample text"" + } + ] + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var result = new CustomSource(); + + result.Content.Should().BeEmpty(); + result.Type.Should().Be("content"); + } + + [Fact] + public void Constructor_WhenCalledWithContent_ItShouldSetProperties() + { + var content = new List + { + new("Sample text") + }; + + var result = new CustomSource(content); + + result.Content.Should().BeSameAs(content); + result.Type.Should().Be("content"); + } + + [Fact] + public void Constructor_WhenCalledWithNullContent_ItShouldThrowArgumentNullException() + { + var act = () => new CustomSource(null!); + + act.Should().Throw(); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var content = new List + { + new("Sample text") + }; + var source = new CustomSource(content); + + var result = Serialize(source); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var source = Deserialize(_testJson); + + var customSource = source.As(); + customSource!.Type.Should().Be("content"); + customSource.Content.Should().HaveCount(1); + customSource.Content[0].Type.Should().Be("text"); + customSource.Content[0].Text.Should().Be("Sample text"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs b/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs index 99525d5..c5c4971 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs @@ -87,6 +87,28 @@ public void Constructor_WhenCalledWithCacheControlAndDataIsNull_ItShouldThrowArg action.Should().Throw(); } + [Fact] + public void Constructor_WhenCalledWithSource_ItShouldInitializeSource() + { + var source = new DocumentSource("application/pdf", "data"); + + var result = new DocumentContent(source); + + result.Source.Should().BeSameAs(source); + } + + [Fact] + public void Constructor_WhenCalledWithSourceAndCacheControl_ItShouldInitializeSourceAndCacheControl() + { + var source = new DocumentSource("application/pdf", "data"); + var cacheControl = new EphemeralCacheControl(); + + var result = new DocumentContent(source, cacheControl); + + result.Source.Should().BeSameAs(source); + result.CacheControl.Should().BeSameAs(cacheControl); + } + [Fact] public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() { diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs index 2e7e87c..2210e90 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs @@ -74,7 +74,7 @@ public class MessageRequestTests : SerializationTest ""content"": [ { ""type"": ""image"", - ""source"": { ""media_type"": ""image/jpeg"", ""data"": ""data"" } + ""source"": { ""type"": ""base64"", ""media_type"": ""image/jpeg"", ""data"": ""data"" } } ] } @@ -558,8 +558,9 @@ public void JsonDeserialization_WhenDeserializedWithImageContent_ItShouldHaveExp var imageContent = messageRequest.Messages[0].Content[0] as ImageContent; imageContent!.Type.Should().Be("image"); - imageContent.Source.MediaType.Should().Be("image/jpeg"); - imageContent.Source.Data.Should().Be("data"); + + imageContent.Source.As().MediaType.Should().Be("image/jpeg"); + imageContent.Source.As().Data.Should().Be("data"); } [Fact] diff --git a/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs new file mode 100644 index 0000000..b97b629 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs @@ -0,0 +1,58 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class PageLocationCitationTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""page_location"", + ""cited_text"": ""cited text"", + ""document_index"": 1, + ""document_title"": ""document title"", + ""start_page_number"": 2, + ""end_page_number"": 3 + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var result = new PageLocationCitation(); + + result.Type.Should().Be("page_location"); + result.CitedText.Should().BeEmpty(); + result.DocumentIndex.Should().Be(0); + result.DocumentTitle.Should().BeEmpty(); + result.StartPageNumber.Should().Be(0); + result.EndPageNumber.Should().Be(0); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var citation = new PageLocationCitation + { + Type = "page_location", + CitedText = "cited text", + DocumentIndex = 1, + DocumentTitle = "document title", + StartPageNumber = 2, + EndPageNumber = 3 + }; + + var result = Serialize(citation); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var citation = Deserialize(_testJson); + + var pageLocationCitation = citation.As(); + pageLocationCitation!.Type.Should().Be("page_location"); + pageLocationCitation.CitedText.Should().Be("cited text"); + pageLocationCitation.DocumentIndex.Should().Be(1); + pageLocationCitation.DocumentTitle.Should().Be("document title"); + pageLocationCitation.StartPageNumber.Should().Be(2); + pageLocationCitation.EndPageNumber.Should().Be(3); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs b/tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs new file mode 100644 index 0000000..1e091ab --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs @@ -0,0 +1,22 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class SourceTypeTests +{ + [Fact] + public void Base64_WhenCalled_ItShouldReturnCorrectValue() + { + SourceType.Base64.Should().Be("base64"); + } + + [Fact] + public void Content_WhenCalled_ItShouldReturnCorrectValue() + { + SourceType.Content.Should().Be("content"); + } + + [Fact] + public void Text_WhenCalled_ItShouldReturnCorrectValue() + { + SourceType.Text.Should().Be("text"); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs new file mode 100644 index 0000000..dd9780c --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs @@ -0,0 +1,41 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class TextSourceTests : SerializationTest +{ + private readonly string _testJson = @"{ + ""type"": ""text"", + ""media_type"": ""text/plain"", + ""data"": ""data"" + }"; + + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var result = new TextSource("data"); + + result.Type.Should().Be("text"); + result.MediaType.Should().Be("text/plain"); + result.Data.Should().Be("data"); + } + + [Fact] + public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape() + { + var source = new TextSource("data"); + + var result = Serialize(source); + + JsonAssert.Equal(_testJson, result); + } + + [Fact] + public void JsonDeserialization_WhenDeserialized_ItShouldHaveExpectedShape() + { + var source = Deserialize(_testJson); + + var textSource = source.As(); + textSource!.Type.Should().Be("text"); + textSource.MediaType.Should().Be("text/plain"); + textSource.Data.Should().Be("data"); + } +} \ No newline at end of file