From 8326e1749d0701d75a811188296c1716aa43f1fa Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:43:50 -0500 Subject: [PATCH 01/13] chore: update editorconfig --- .editorconfig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.editorconfig b/.editorconfig index 60b870e..93684b2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -77,6 +77,9 @@ 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 From a9d0dfdc7607ff6d6e60afbfbe7c14973ff113a8 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 10 Jun 2025 23:34:33 -0500 Subject: [PATCH 02/13] feat: initial pass at citations in request and response --- src/AnthropicClient/Json/CitationConverter.cs | 28 ++++++++++++ .../Json/JsonSerializationOptions.cs | 1 + src/AnthropicClient/Models/CitationType.cs | 8 ++++ src/AnthropicClient/Models/DocumentContent.cs | 26 +++++++++++ src/AnthropicClient/Models/TextContent.cs | 43 +++++++++++++++++++ .../Models/TextDocumentSource.cs | 11 +++++ .../EndToEnd/AnthropicApiClientTests.cs | 29 +++++++++++++ 7 files changed, 146 insertions(+) create mode 100644 src/AnthropicClient/Json/CitationConverter.cs create mode 100644 src/AnthropicClient/Models/CitationType.cs create mode 100644 src/AnthropicClient/Models/TextDocumentSource.cs diff --git a/src/AnthropicClient/Json/CitationConverter.cs b/src/AnthropicClient/Json/CitationConverter.cs new file mode 100644 index 0000000..db4cf7c --- /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 content 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/JsonSerializationOptions.cs b/src/AnthropicClient/Json/JsonSerializationOptions.cs index c1d5c40..5e426b3 100644 --- a/src/AnthropicClient/Json/JsonSerializationOptions.cs +++ b/src/AnthropicClient/Json/JsonSerializationOptions.cs @@ -18,6 +18,7 @@ static class JsonSerializationOptions new ContentDeltaConverter(), new JsonStringEnumConverter(), new MessageBatchResultConverter(), + new CitationConverter(), }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; diff --git a/src/AnthropicClient/Models/CitationType.cs b/src/AnthropicClient/Models/CitationType.cs new file mode 100644 index 0000000..daeda7e --- /dev/null +++ b/src/AnthropicClient/Models/CitationType.cs @@ -0,0 +1,8 @@ +namespace AnthropicClient.Models; + +public static class CitationType +{ + public const string CharacterLocation = "char_location"; + public const string PageLocation = "page_location"; + public const string ContentBlockLocation = "content_block_location"; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/DocumentContent.cs b/src/AnthropicClient/Models/DocumentContent.cs index 1389d84..cd86c29 100644 --- a/src/AnthropicClient/Models/DocumentContent.cs +++ b/src/AnthropicClient/Models/DocumentContent.cs @@ -14,6 +14,12 @@ public class DocumentContent : Content /// public DocumentSource Source { get; init; } = new(); + public string Title { get; init; } = string.Empty; + + public string Context { get; init; } = string.Empty; + + public CitationOption Citations { get; init; } = new CitationOption(); + [JsonConstructor] internal DocumentContent() { @@ -53,4 +59,24 @@ public DocumentContent(string mediaType, string data, CacheControl cacheControl) Source = new(mediaType, data); } + + public DocumentContent(DocumentSource source) : base(ContentType.Document) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + + Source = source; + } + + public DocumentContent(DocumentSource source, CacheControl cacheControl) : base(ContentType.Document, cacheControl) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + + Source = source; + } +} + + +public class CitationOption +{ + public bool Enabled { get; init; } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextContent.cs b/src/AnthropicClient/Models/TextContent.cs index a6751cf..924ec1b 100644 --- a/src/AnthropicClient/Models/TextContent.cs +++ b/src/AnthropicClient/Models/TextContent.cs @@ -14,6 +14,8 @@ public class TextContent : Content /// public string Text { get; init; } = string.Empty; + public Citation[] Citations { get; init; } = []; + [JsonConstructor] internal TextContent() : base(ContentType.Text) { @@ -50,4 +52,45 @@ public TextContent(string text, CacheControl cacheControl) : base(ContentType.Te Text = text; } +} + +public abstract class Citation +{ + public string Type { get; init; } = string.Empty; + + [JsonPropertyName("cited_text")] + public string CitedText { get; init; } = string.Empty; + + [JsonPropertyName("document_index")] + public int DocumentIndex { get; init; } + + [JsonPropertyName("document_title")] + public string DocumentTitle { get; init; } = string.Empty; +} + +public class CharacterLocationCitation : Citation +{ + [JsonPropertyName("start_char_index")] + public int StartCharIndex { get; init; } + + [JsonPropertyName("end_char_index")] + public int EndCharIndex { get; init; } +} + +public class PageLocationCitation : Citation +{ + [JsonPropertyName("start_page_number")] + public int StartPageNumber { get; init; } + + [JsonPropertyName("end_page_number")] + public int EndPageNumber { get; init; } +} + +public class ContentBlockLocationCitation : Citation +{ + [JsonPropertyName("start_block_index")] + public int StartBlockIndex { get; init; } + + [JsonPropertyName("end_block_index")] + public int EndBlockIndex { get; init; } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextDocumentSource.cs b/src/AnthropicClient/Models/TextDocumentSource.cs new file mode 100644 index 0000000..d4dbf71 --- /dev/null +++ b/src/AnthropicClient/Models/TextDocumentSource.cs @@ -0,0 +1,11 @@ +namespace AnthropicClient.Models; + +public class TextDocumentSource : DocumentSource +{ + public TextDocumentSource(string data) : base("text/plain", data) + { + Type = "text"; + } +} + + diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 8fa81f6..230e0d3 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -286,6 +286,35 @@ public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShoul resultTwo.Value.Usage.CacheReadInputTokens.Should().BeGreaterThan(0); } + [Fact] + public async Task CreateMessageAsync_WhenCitationsAreEnabled_ItShouldReturnCitationsInResponse() + { + var request = new MessageRequest( + model: AnthropicModels.Claude3Haiku, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent( + new TextDocumentSource("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(); + result.Value.Content.OfType().SelectMany(c => c.Citations).Should().NotBeEmpty(); + } + [Fact] public async Task CountMessageTokensAsync_WhenCalled_ItShouldReturnResponse() { From d50989833e74cda6068eb87cbfbef73d4e42cc97 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 10 Jun 2025 23:54:43 -0500 Subject: [PATCH 03/13] chore: update editor config/fix analyzer suggestions --- .editorconfig | 12 ++++++++---- .../EndToEnd/AnthropicApiClientTests.cs | 12 ++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.editorconfig b/.editorconfig index 93684b2..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 @@ -81,9 +85,9 @@ dotnet_remove_unnecessary_suppression_exclusions = none 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 @@ -121,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/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 230e0d3..1e0bafb 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?")])); @@ -312,7 +312,7 @@ public async Task CreateMessageAsync_WhenCitationsAreEnabled_ItShouldReturnCitat var result = await _client.CreateMessageAsync(request); result.IsSuccess.Should().BeTrue(); - result.Value.Content.OfType().SelectMany(c => c.Citations).Should().NotBeEmpty(); + result.Value.Content.OfType().SelectMany(static c => c.Citations).Should().NotBeEmpty(); } [Fact] From 5b8d88a01d2f1933ddf60bfffa908e8e220bef8c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 14 Jun 2025 13:44:10 -0500 Subject: [PATCH 04/13] feat: add support for citations BREAKING CHANGE: Changed type of public Source properties. Updated `Source` property on `DocumentContent` and `ImageContent` types to use base `Source` type - include support for citing custom sources - include support for citing PDF sources - include support for citing text sources --- .../Json/JsonSerializationOptions.cs | 1 + src/AnthropicClient/Json/SourceConverter.cs | 46 ++++++++++ src/AnthropicClient/Models/Base64Source.cs | 39 ++++++++ .../Models/CharacterLocationCitation.cs | 21 +++++ src/AnthropicClient/Models/Citation.cs | 32 +++++++ src/AnthropicClient/Models/CitationOption.cs | 12 +++ src/AnthropicClient/Models/CitationType.cs | 14 +++ .../Models/ContentBlockLocationCitation.cs | 21 +++++ src/AnthropicClient/Models/CustomSource.cs | 33 +++++++ src/AnthropicClient/Models/DocumentContent.cs | 44 ++++++--- src/AnthropicClient/Models/DocumentSource.cs | 27 +----- src/AnthropicClient/Models/ImageContent.cs | 6 +- src/AnthropicClient/Models/ImageSource.cs | 26 +----- .../Models/PageLocationCitation.cs | 21 +++++ src/AnthropicClient/Models/Source.cs | 22 +++++ src/AnthropicClient/Models/SourceType.cs | 22 +++++ src/AnthropicClient/Models/TextContent.cs | 46 +--------- .../Models/TextDocumentSource.cs | 11 --- src/AnthropicClient/Models/TextSource.cs | 33 +++++++ .../EndToEnd/AnthropicApiClientTests.cs | 91 ++++++++++++++++++- .../Unit/Models/MessageRequestTests.cs | 5 +- 21 files changed, 450 insertions(+), 123 deletions(-) create mode 100644 src/AnthropicClient/Json/SourceConverter.cs create mode 100644 src/AnthropicClient/Models/Base64Source.cs create mode 100644 src/AnthropicClient/Models/CharacterLocationCitation.cs create mode 100644 src/AnthropicClient/Models/Citation.cs create mode 100644 src/AnthropicClient/Models/CitationOption.cs create mode 100644 src/AnthropicClient/Models/ContentBlockLocationCitation.cs create mode 100644 src/AnthropicClient/Models/CustomSource.cs create mode 100644 src/AnthropicClient/Models/PageLocationCitation.cs create mode 100644 src/AnthropicClient/Models/Source.cs create mode 100644 src/AnthropicClient/Models/SourceType.cs delete mode 100644 src/AnthropicClient/Models/TextDocumentSource.cs create mode 100644 src/AnthropicClient/Models/TextSource.cs diff --git a/src/AnthropicClient/Json/JsonSerializationOptions.cs b/src/AnthropicClient/Json/JsonSerializationOptions.cs index 5e426b3..355aae8 100644 --- a/src/AnthropicClient/Json/JsonSerializationOptions.cs +++ b/src/AnthropicClient/Json/JsonSerializationOptions.cs @@ -19,6 +19,7 @@ static class JsonSerializationOptions 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..3847e8d --- /dev/null +++ b/src/AnthropicClient/Json/SourceConverter.cs @@ -0,0 +1,46 @@ +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 => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown content type: {type}") + }; + } + + 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..b175b7b --- /dev/null +++ b/src/AnthropicClient/Models/Base64Source.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +using AnthropicClient.Utils; + +namespace AnthropicClient.Models; + +/// +/// Represents an 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..0b461cb --- /dev/null +++ b/src/AnthropicClient/Models/CharacterLocationCitation.cs @@ -0,0 +1,21 @@ +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; } +} diff --git a/src/AnthropicClient/Models/Citation.cs b/src/AnthropicClient/Models/Citation.cs new file mode 100644 index 0000000..ec7ae73 --- /dev/null +++ b/src/AnthropicClient/Models/Citation.cs @@ -0,0 +1,32 @@ +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; +} 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 index daeda7e..d0b5536 100644 --- a/src/AnthropicClient/Models/CitationType.cs +++ b/src/AnthropicClient/Models/CitationType.cs @@ -1,8 +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..0cbb73a --- /dev/null +++ b/src/AnthropicClient/Models/ContentBlockLocationCitation.cs @@ -0,0 +1,21 @@ +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; } +} \ 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..98390ed --- /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; + } +} diff --git a/src/AnthropicClient/Models/DocumentContent.cs b/src/AnthropicClient/Models/DocumentContent.cs index cd86c29..2e93b31 100644 --- a/src/AnthropicClient/Models/DocumentContent.cs +++ b/src/AnthropicClient/Models/DocumentContent.cs @@ -12,13 +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(); - public string Title { get; init; } = string.Empty; + /// + /// Gets the title of the document. + /// + public string? Title { get; init; } - public string Context { get; init; } = string.Empty; + /// + /// Gets the context of the document. + /// + public string? Context { get; init; } - public CitationOption Citations { get; init; } = new CitationOption(); + /// + /// Gets whether citations are enabled for the document. + /// + public CitationOption? Citations { get; init; } [JsonConstructor] internal DocumentContent() @@ -42,7 +51,7 @@ public DocumentContent(string mediaType, string data) : base(ContentType.Documen { Validate(mediaType, data); - Source = new(mediaType, data); + Source = new DocumentSource(mediaType, data); } /// @@ -57,26 +66,33 @@ public DocumentContent(string mediaType, string data, CacheControl cacheControl) { Validate(mediaType, data); - Source = new(mediaType, data); + Source = new DocumentSource(mediaType, data); } - public DocumentContent(DocumentSource source) : base(ContentType.Document) + /// + /// 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; } - public DocumentContent(DocumentSource source, CacheControl cacheControl) : base(ContentType.Document, cacheControl) + /// + /// 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; } } - - -public class CitationOption -{ - public bool Enabled { get; init; } -} \ 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..040c262 --- /dev/null +++ b/src/AnthropicClient/Models/PageLocationCitation.cs @@ -0,0 +1,21 @@ +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; } +} 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 924ec1b..4984783 100644 --- a/src/AnthropicClient/Models/TextContent.cs +++ b/src/AnthropicClient/Models/TextContent.cs @@ -14,7 +14,10 @@ public class TextContent : Content /// public string Text { get; init; } = string.Empty; - public Citation[] Citations { get; init; } = []; + /// + /// Gets the citations associated with the text content. + /// + public Citation[]? Citations { get; init; } [JsonConstructor] internal TextContent() : base(ContentType.Text) @@ -53,44 +56,3 @@ public TextContent(string text, CacheControl cacheControl) : base(ContentType.Te Text = text; } } - -public abstract class Citation -{ - public string Type { get; init; } = string.Empty; - - [JsonPropertyName("cited_text")] - public string CitedText { get; init; } = string.Empty; - - [JsonPropertyName("document_index")] - public int DocumentIndex { get; init; } - - [JsonPropertyName("document_title")] - public string DocumentTitle { get; init; } = string.Empty; -} - -public class CharacterLocationCitation : Citation -{ - [JsonPropertyName("start_char_index")] - public int StartCharIndex { get; init; } - - [JsonPropertyName("end_char_index")] - public int EndCharIndex { get; init; } -} - -public class PageLocationCitation : Citation -{ - [JsonPropertyName("start_page_number")] - public int StartPageNumber { get; init; } - - [JsonPropertyName("end_page_number")] - public int EndPageNumber { get; init; } -} - -public class ContentBlockLocationCitation : Citation -{ - [JsonPropertyName("start_block_index")] - public int StartBlockIndex { get; init; } - - [JsonPropertyName("end_block_index")] - public int EndBlockIndex { get; init; } -} \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextDocumentSource.cs b/src/AnthropicClient/Models/TextDocumentSource.cs deleted file mode 100644 index d4dbf71..0000000 --- a/src/AnthropicClient/Models/TextDocumentSource.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace AnthropicClient.Models; - -public class TextDocumentSource : DocumentSource -{ - public TextDocumentSource(string data) : base("text/plain", data) - { - Type = "text"; - } -} - - diff --git a/src/AnthropicClient/Models/TextSource.cs b/src/AnthropicClient/Models/TextSource.cs new file mode 100644 index 0000000..9549b0c --- /dev/null +++ b/src/AnthropicClient/Models/TextSource.cs @@ -0,0 +1,33 @@ +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; + } +} + + diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 1e0bafb..7c4ca20 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -287,16 +287,16 @@ public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShoul } [Fact] - public async Task CreateMessageAsync_WhenCitationsAreEnabled_ItShouldReturnCitationsInResponse() + public async Task CreateMessageAsync_WhenCitationsAreEnabledForTextDocumentSource_ItShouldReturnCitationsInResponse() { var request = new MessageRequest( - model: AnthropicModels.Claude3Haiku, + model: AnthropicModels.Claude35HaikuLatest, messages: [ new( MessageRole.User, [ new DocumentContent( - new TextDocumentSource("The grass is green. The sky is blue.") + new TextSource("The grass is green. The sky is blue.") ) { Title = "My Document", @@ -312,7 +312,90 @@ public async Task CreateMessageAsync_WhenCitationsAreEnabled_ItShouldReturnCitat var result = await _client.CreateMessageAsync(request); result.IsSuccess.Should().BeTrue(); - result.Value.Content.OfType().SelectMany(static c => c.Citations).Should().NotBeEmpty(); + + var citations = result.Value + .Content + .OfType() + .SelectMany(static c => + { + return 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] diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs index 2e7e87c..da3f848 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs @@ -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] From 74ac1ffc06327e63571cf89050759747776bce25 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 14 Jun 2025 14:01:18 -0500 Subject: [PATCH 05/13] fix: when serializing source use media type to deserialize to image or document source --- src/AnthropicClient/Json/SourceConverter.cs | 11 ++++++++++- .../Unit/Models/MessageRequestTests.cs | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/AnthropicClient/Json/SourceConverter.cs b/src/AnthropicClient/Json/SourceConverter.cs index 3847e8d..642295d 100644 --- a/src/AnthropicClient/Json/SourceConverter.cs +++ b/src/AnthropicClient/Json/SourceConverter.cs @@ -16,11 +16,20 @@ public override Source Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS { SourceType.Text => JsonSerializer.Deserialize(root.GetRawText(), options)!, SourceType.Content => JsonSerializer.Deserialize(root.GetRawText(), options)!, - SourceType.Base64 => JsonSerializer.Deserialize(root.GetRawText(), options)!, + SourceType.Base64 => DeserializeBase64Source(root, options), _ => throw new JsonException($"Unknown content type: {type}") }; } + private static Source DeserializeBase64Source(JsonElement root, JsonSerializerOptions options) + { + var mediaType = root.GetProperty("media_type").GetString() ?? 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) diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageRequestTests.cs index da3f848..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"" } } ] } From 98da1384c154e0300e44d41cd160c15e522e7dbd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 14 Jun 2025 22:24:44 -0500 Subject: [PATCH 06/13] feat: add support for citations when streaming --- src/AnthropicClient/AnthropicApiClient.cs | 33 ++++- .../Json/ContentDeltaConverter.cs | 1 + src/AnthropicClient/Models/CitationDelta.cs | 30 +++++ .../Models/ContentDeltaType.cs | 5 + .../EndToEnd/AnthropicApiClientTests.cs | 126 +++++++++++++++++- 5 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 src/AnthropicClient/Models/CitationDelta.cs 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/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/Models/CitationDelta.cs b/src/AnthropicClient/Models/CitationDelta.cs new file mode 100644 index 0000000..1d27bd0 --- /dev/null +++ b/src/AnthropicClient/Models/CitationDelta.cs @@ -0,0 +1,30 @@ +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. + /// + 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/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/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 7c4ca20..cb1b9fe 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -316,10 +316,7 @@ public async Task CreateMessageAsync_WhenCitationsAreEnabledForTextDocumentSourc var citations = result.Value .Content .OfType() - .SelectMany(static c => - { - return c.Citations is null ? [] : c.Citations; - }); + .SelectMany(static c => c.Citations is null ? [] : c.Citations); citations.OfType().Should().NotBeEmpty(); } @@ -398,6 +395,127 @@ public async Task CreateMessageAsync_WhenCitationsAreEnabledForCustomDocumentSou 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() { From 0c62a7cfccbc7b0b894a6c1e8643ac52513f4c17 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 15 Jun 2025 11:49:48 -0500 Subject: [PATCH 07/13] tests: adding missing tests for newly introduced classes --- .../Models/CharacterLocationCitation.cs | 8 ++++ src/AnthropicClient/Models/Citation.cs | 15 +++++++ src/AnthropicClient/Models/CitationDelta.cs | 3 ++ .../Models/ContentBlockLocationCitation.cs | 8 ++++ .../Models/PageLocationCitation.cs | 8 ++++ .../Unit/Models/Base64SourceTests.cs | 39 +++++++++++++++++++ .../Models/CharacterLocationCitationTests.cs | 17 ++++++++ .../Unit/Models/CitationDeltaTests.cs | 31 +++++++++++++++ .../Unit/Models/CitationOptionTests.cs | 12 ++++++ .../Unit/Models/CitationTypeTests.cs | 22 +++++++++++ .../ContentBlockLocationCitationTests.cs | 17 ++++++++ .../Unit/Models/ContentDeltaTypeTests.cs | 10 +++++ 12 files changed, 190 insertions(+) create mode 100644 tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CitationDeltaTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CitationOptionTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CitationTypeTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs diff --git a/src/AnthropicClient/Models/CharacterLocationCitation.cs b/src/AnthropicClient/Models/CharacterLocationCitation.cs index 0b461cb..678326e 100644 --- a/src/AnthropicClient/Models/CharacterLocationCitation.cs +++ b/src/AnthropicClient/Models/CharacterLocationCitation.cs @@ -18,4 +18,12 @@ public class CharacterLocationCitation : 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) + { + } } diff --git a/src/AnthropicClient/Models/Citation.cs b/src/AnthropicClient/Models/Citation.cs index ec7ae73..485f917 100644 --- a/src/AnthropicClient/Models/Citation.cs +++ b/src/AnthropicClient/Models/Citation.cs @@ -29,4 +29,19 @@ public abstract class Citation /// [JsonPropertyName("document_title")] public string DocumentTitle { get; init; } = string.Empty; + + [JsonConstructor] + internal Citation() + { + } + + /// + /// 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; + } } diff --git a/src/AnthropicClient/Models/CitationDelta.cs b/src/AnthropicClient/Models/CitationDelta.cs index 1d27bd0..e299d97 100644 --- a/src/AnthropicClient/Models/CitationDelta.cs +++ b/src/AnthropicClient/Models/CitationDelta.cs @@ -22,6 +22,9 @@ 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)); diff --git a/src/AnthropicClient/Models/ContentBlockLocationCitation.cs b/src/AnthropicClient/Models/ContentBlockLocationCitation.cs index 0cbb73a..f0383bb 100644 --- a/src/AnthropicClient/Models/ContentBlockLocationCitation.cs +++ b/src/AnthropicClient/Models/ContentBlockLocationCitation.cs @@ -18,4 +18,12 @@ public class ContentBlockLocationCitation : 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/PageLocationCitation.cs b/src/AnthropicClient/Models/PageLocationCitation.cs index 040c262..29a9ce1 100644 --- a/src/AnthropicClient/Models/PageLocationCitation.cs +++ b/src/AnthropicClient/Models/PageLocationCitation.cs @@ -18,4 +18,12 @@ public class PageLocationCitation : 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) + { + } } diff --git a/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs new file mode 100644 index 0000000..1a6972b --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs @@ -0,0 +1,39 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class Base64SourceTests +{ + [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(); + } +} \ 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..0043ea4 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs @@ -0,0 +1,17 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CharacterLocationCitationTests +{ + [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); + } +} \ 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..8352ce8 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CitationDeltaTests.cs @@ -0,0 +1,31 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CitationDeltaTests +{ + [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(); + } +} \ 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..45fc169 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs @@ -0,0 +1,17 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ContentBlockLocationCitationTests +{ + [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); + } +} \ 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 From d2173555bc0e4614b47d9e640d7956c054193f87 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 15 Jun 2025 11:50:09 -0500 Subject: [PATCH 08/13] chore: run dotnet format --- src/AnthropicClient/Models/CharacterLocationCitation.cs | 2 +- src/AnthropicClient/Models/Citation.cs | 2 +- src/AnthropicClient/Models/CustomSource.cs | 2 +- src/AnthropicClient/Models/DocumentContent.cs | 2 +- src/AnthropicClient/Models/PageLocationCitation.cs | 2 +- src/AnthropicClient/Models/TextContent.cs | 2 +- src/AnthropicClient/Models/TextSource.cs | 4 +--- 7 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/AnthropicClient/Models/CharacterLocationCitation.cs b/src/AnthropicClient/Models/CharacterLocationCitation.cs index 678326e..0323d07 100644 --- a/src/AnthropicClient/Models/CharacterLocationCitation.cs +++ b/src/AnthropicClient/Models/CharacterLocationCitation.cs @@ -26,4 +26,4 @@ public class CharacterLocationCitation : Citation 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 index 485f917..db42878 100644 --- a/src/AnthropicClient/Models/Citation.cs +++ b/src/AnthropicClient/Models/Citation.cs @@ -44,4 +44,4 @@ protected Citation(string type) { Type = type; } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/CustomSource.cs b/src/AnthropicClient/Models/CustomSource.cs index 98390ed..70dabb5 100644 --- a/src/AnthropicClient/Models/CustomSource.cs +++ b/src/AnthropicClient/Models/CustomSource.cs @@ -30,4 +30,4 @@ public CustomSource(List content) : base(SourceType.Content) Content = content; } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/DocumentContent.cs b/src/AnthropicClient/Models/DocumentContent.cs index 2e93b31..3f989a0 100644 --- a/src/AnthropicClient/Models/DocumentContent.cs +++ b/src/AnthropicClient/Models/DocumentContent.cs @@ -95,4 +95,4 @@ public DocumentContent(Source source, CacheControl cacheControl) : base(ContentT Source = source; } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/PageLocationCitation.cs b/src/AnthropicClient/Models/PageLocationCitation.cs index 29a9ce1..1283e9b 100644 --- a/src/AnthropicClient/Models/PageLocationCitation.cs +++ b/src/AnthropicClient/Models/PageLocationCitation.cs @@ -26,4 +26,4 @@ public class PageLocationCitation : Citation public PageLocationCitation() : base(CitationType.PageLocation) { } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextContent.cs b/src/AnthropicClient/Models/TextContent.cs index 4984783..5320567 100644 --- a/src/AnthropicClient/Models/TextContent.cs +++ b/src/AnthropicClient/Models/TextContent.cs @@ -55,4 +55,4 @@ public TextContent(string text, CacheControl cacheControl) : base(ContentType.Te Text = text; } -} +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextSource.cs b/src/AnthropicClient/Models/TextSource.cs index 9549b0c..e220a13 100644 --- a/src/AnthropicClient/Models/TextSource.cs +++ b/src/AnthropicClient/Models/TextSource.cs @@ -28,6 +28,4 @@ public TextSource(string data) : base(SourceType.Text) { Data = data; } -} - - +} \ No newline at end of file From 68e91fcdbb032fe333744f4d2f5e696891f0c240 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:23:57 -0500 Subject: [PATCH 09/13] feat: add unit tests for DocumentContent, CustomSource, PageLocationCitation, SourceType, and TextSource classes chore: update VSCode settings to exclude docs from search --- .vscode/settings.json | 5 ++- .../Unit/Models/CustomSourceTests.cs | 35 +++++++++++++++++++ .../Unit/Models/DocumentContentTests.cs | 12 +++++++ .../Unit/Models/PageLocationCitationTests.cs | 17 +++++++++ .../Unit/Models/SourceTypeTests.cs | 22 ++++++++++++ .../Unit/Models/TextSourceTests.cs | 14 ++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/SourceTypeTests.cs create mode 100644 tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs 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/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs new file mode 100644 index 0000000..53d8379 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs @@ -0,0 +1,35 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class CustomSourceTests +{ + [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(); + } +} \ 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..1687bdb 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs @@ -87,6 +87,18 @@ public void Constructor_WhenCalledWithCacheControlAndDataIsNull_ItShouldThrowArg action.Should().Throw(); } + [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/PageLocationCitationTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs new file mode 100644 index 0000000..37cc3a4 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs @@ -0,0 +1,17 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class PageLocationCitationTests +{ + [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); + } +} \ 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..eacdff3 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs @@ -0,0 +1,14 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class TextSourceTests +{ + [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"); + } +} \ No newline at end of file From 598eaaad9412c5bbbe9e28d2d87fcb0b38592fd6 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 22 Jun 2025 10:52:14 -0500 Subject: [PATCH 10/13] feat: add serialization tests for CharacterLocationCitation, CitationDelta, ContentBlockLocationCitation, PageLocationCitation, and DocumentContent classes --- .../Unit/Json/CitationConverterTests.cs | 14 ++++++ .../Models/CharacterLocationCitationTests.cs | 43 +++++++++++++++- .../Unit/Models/CitationDeltaTests.cs | 49 ++++++++++++++++++- .../ContentBlockLocationCitationTests.cs | 43 +++++++++++++++- .../Unit/Models/DocumentContentTests.cs | 10 ++++ .../Unit/Models/PageLocationCitationTests.cs | 43 +++++++++++++++- 6 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 tests/AnthropicClient.Tests/Unit/Json/CitationConverterTests.cs 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/Models/CharacterLocationCitationTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs index 0043ea4..397c572 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/CharacterLocationCitationTests.cs @@ -1,7 +1,16 @@ namespace AnthropicClient.Tests.Unit.Models; -public class CharacterLocationCitationTests +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() { @@ -14,4 +23,36 @@ public void Constructor_WhenCalled_ItShouldSetProperties() 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 index 8352ce8..2f6d691 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/CitationDeltaTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/CitationDeltaTests.cs @@ -1,7 +1,19 @@ namespace AnthropicClient.Tests.Unit.Models; -public class CitationDeltaTests +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() { @@ -28,4 +40,39 @@ public void Constructor_WhenCalledWithNullCitation_ItShouldThrowException() 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/ContentBlockLocationCitationTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs index 45fc169..8188f0c 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/ContentBlockLocationCitationTests.cs @@ -1,7 +1,16 @@ namespace AnthropicClient.Tests.Unit.Models; -public class ContentBlockLocationCitationTests +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() { @@ -14,4 +23,36 @@ public void Constructor_WhenCalled_ItShouldSetProperties() 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/DocumentContentTests.cs b/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs index 1687bdb..c5c4971 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/DocumentContentTests.cs @@ -87,6 +87,16 @@ 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() { diff --git a/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs b/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs index 37cc3a4..b97b629 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/PageLocationCitationTests.cs @@ -1,7 +1,16 @@ namespace AnthropicClient.Tests.Unit.Models; -public class PageLocationCitationTests +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() { @@ -14,4 +23,36 @@ public void Constructor_WhenCalled_ItShouldSetProperties() 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 From 2732ae2b21967ae1abcee7ef2262a84e2e8c315f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:56:13 -0500 Subject: [PATCH 11/13] tests: add additional test coverage --- src/AnthropicClient/Json/SourceConverter.cs | 6 ++- src/AnthropicClient/Models/Citation.cs | 5 -- .../Data/EventTestData.cs | 48 ++++++++++++++++- .../Unit/Json/SourceConverterTests.cs | 31 +++++++++++ .../Unit/Models/Base64SourceTests.cs | 51 ++++++++++++++++++- .../Unit/Models/CustomSourceTests.cs | 38 +++++++++++++- .../Unit/Models/TextSourceTests.cs | 29 ++++++++++- 7 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 tests/AnthropicClient.Tests/Unit/Json/SourceConverterTests.cs diff --git a/src/AnthropicClient/Json/SourceConverter.cs b/src/AnthropicClient/Json/SourceConverter.cs index 642295d..f8dd163 100644 --- a/src/AnthropicClient/Json/SourceConverter.cs +++ b/src/AnthropicClient/Json/SourceConverter.cs @@ -23,8 +23,12 @@ public override Source Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS private static Source DeserializeBase64Source(JsonElement root, JsonSerializerOptions options) { - var mediaType = root.GetProperty("media_type").GetString() ?? throw new JsonException("Missing 'media_type' property"); + 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)!; diff --git a/src/AnthropicClient/Models/Citation.cs b/src/AnthropicClient/Models/Citation.cs index db42878..e502e5b 100644 --- a/src/AnthropicClient/Models/Citation.cs +++ b/src/AnthropicClient/Models/Citation.cs @@ -30,11 +30,6 @@ public abstract class Citation [JsonPropertyName("document_title")] public string DocumentTitle { get; init; } = string.Empty; - [JsonConstructor] - internal Citation() - { - } - /// /// Initializes a new instance of the class with a specified type. /// 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/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 index 1a6972b..33761d7 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/Base64SourceTests.cs @@ -1,7 +1,13 @@ namespace AnthropicClient.Tests.Unit.Models; -public class Base64SourceTests +public class Base64SourceTests : SerializationTest { + private readonly string _testJson = @"{ + ""type"": ""base64"", + ""media_type"": ""application/pdf"", + ""data"": ""base64data"" + }"; + [Fact] public void Constructor_WhenCalledWithValidArguments_ItShouldSetProperties() { @@ -36,4 +42,47 @@ public void Constructor_WhenCalledWithNullData_ItShouldThrowArgumentNullExceptio 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/CustomSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs index 53d8379..fd7dc0b 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/CustomSourceTests.cs @@ -1,7 +1,17 @@ namespace AnthropicClient.Tests.Unit.Models; -public class CustomSourceTests +public class CustomSourceTests : SerializationTest { + private readonly string _testJson = @"{ + ""type"": ""content"", + ""content"": [ + { + ""type"": ""text"", + ""text"": ""Sample text"" + } + ] + }"; + [Fact] public void Constructor_WhenCalled_ItShouldSetProperties() { @@ -32,4 +42,30 @@ public void Constructor_WhenCalledWithNullContent_ItShouldThrowArgumentNullExcep 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/TextSourceTests.cs b/tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs index eacdff3..dd9780c 100644 --- a/tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs +++ b/tests/AnthropicClient.Tests/Unit/Models/TextSourceTests.cs @@ -1,7 +1,13 @@ namespace AnthropicClient.Tests.Unit.Models; -public class TextSourceTests +public class TextSourceTests : SerializationTest { + private readonly string _testJson = @"{ + ""type"": ""text"", + ""media_type"": ""text/plain"", + ""data"": ""data"" + }"; + [Fact] public void Constructor_WhenCalled_ItShouldSetProperties() { @@ -11,4 +17,25 @@ public void Constructor_WhenCalled_ItShouldSetProperties() 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 From e351823f37828f93305884d60a5d6601db2eba08 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:04:24 -0500 Subject: [PATCH 12/13] fix: address copilot comments --- src/AnthropicClient/Json/CitationConverter.cs | 2 +- src/AnthropicClient/Json/SourceConverter.cs | 2 +- src/AnthropicClient/Models/Base64Source.cs | 2 +- src/AnthropicClient/Models/ContentBlockLocationCitation.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AnthropicClient/Json/CitationConverter.cs b/src/AnthropicClient/Json/CitationConverter.cs index db4cf7c..baa6d9b 100644 --- a/src/AnthropicClient/Json/CitationConverter.cs +++ b/src/AnthropicClient/Json/CitationConverter.cs @@ -17,7 +17,7 @@ public override Citation Read(ref Utf8JsonReader reader, Type typeToConvert, Jso 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 content type: {type}") + _ => throw new JsonException($"Unknown citation type: {type}") }; } diff --git a/src/AnthropicClient/Json/SourceConverter.cs b/src/AnthropicClient/Json/SourceConverter.cs index f8dd163..a68fd70 100644 --- a/src/AnthropicClient/Json/SourceConverter.cs +++ b/src/AnthropicClient/Json/SourceConverter.cs @@ -17,7 +17,7 @@ public override Source Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS SourceType.Text => JsonSerializer.Deserialize(root.GetRawText(), options)!, SourceType.Content => JsonSerializer.Deserialize(root.GetRawText(), options)!, SourceType.Base64 => DeserializeBase64Source(root, options), - _ => throw new JsonException($"Unknown content type: {type}") + _ => throw new JsonException($"Unknown source type: {type}") }; } diff --git a/src/AnthropicClient/Models/Base64Source.cs b/src/AnthropicClient/Models/Base64Source.cs index b175b7b..6a5f597 100644 --- a/src/AnthropicClient/Models/Base64Source.cs +++ b/src/AnthropicClient/Models/Base64Source.cs @@ -5,7 +5,7 @@ namespace AnthropicClient.Models; /// -/// Represents an base64 source. +/// Represents a base64 source. /// public class Base64Source : Source { diff --git a/src/AnthropicClient/Models/ContentBlockLocationCitation.cs b/src/AnthropicClient/Models/ContentBlockLocationCitation.cs index f0383bb..701af71 100644 --- a/src/AnthropicClient/Models/ContentBlockLocationCitation.cs +++ b/src/AnthropicClient/Models/ContentBlockLocationCitation.cs @@ -9,7 +9,7 @@ public class ContentBlockLocationCitation : Citation { /// /// Gets the start block index of the citation. - /// /// + /// [JsonPropertyName("start_block_index")] public int StartBlockIndex { get; init; } From bee8fb1e8194010b5aa40b0829b6ad432a7dcd24 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:13:58 -0500 Subject: [PATCH 13/13] docs: update README.md with citations support information [skip ci] --- README.md | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) 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).