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] 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 class AnthropicApiClient : IAnthropicApiClient // 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 @@ class ContentDeltaConverter : JsonConverter { 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 class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndTo 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 class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndTo 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() {