Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add pdf support #24

Merged
merged 6 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,3 +847,66 @@ foreach (var content in response.Value.Content)
}
}
```

### PDF Support

Anthropic has recently introduced a feature called [PDF Support](https://docs.anthropic.com/en/docs/build-with-claude/pdf-support) that allows Claude to support PDF input and understand both text and visual content within documents. . This feature is covered in depth in [Anthropic's API Documentation](https://docs.anthropic.com/en/docs/build-with-claude/pdf-support).

> [!NOTE]
> This feature is in beta and requires you to set an `anthropic-beta` header on your requests to use it.
> The value of the header should be `pdfs-2024-09-25`.

When using this library you can opt-in to PDF support by adding the required header to the `HttpClient` instance you provide to the `AnthropicApiClient` constructor.

```csharp
using AnthropicClient;

var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25");

var client = new AnthropicApiClient(apiKey, httpClient);
```

PDF support can be used to provide a PDF document as input to the model. This can be used to provide additional context to the model or to ask for additional information from the model. This library aims to make using PDF support convenient by allowing you to provide the PDF document you want Anthropic's models to consider for use when creating a message.

#### PDF Document

You can provide a PDF document by providing its base64 encoded content as a `DocumentContent` instance in the list of messages in the `MessageRequest` or `StreamMessageRequest` constructor.

```csharp
using AnthropicClient;
using AnthropicClient.Models;

var request = new MessageRequest(
model: AnthropicModels.Claude35Sonnet,
messages: [
new(MessageRole.User, [new TextContent("What is the title of this paper?")]),
new(MessageRole.User, [new DocumentContent("application/pdf", base64Data)])
]
);

var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25");

var client = new AnthropicApiClient(apiKey, httpClient);

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(textContent.Text);
break;
}
}
```
1 change: 1 addition & 0 deletions src/AnthropicClient/Json/ContentConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public override Content Read(ref Utf8JsonReader reader, Type typeToConvert, Json
{
ContentType.Text => JsonSerializer.Deserialize<TextContent>(root.GetRawText(), options)!,
ContentType.Image => JsonSerializer.Deserialize<ImageContent>(root.GetRawText(), options)!,
ContentType.Document => JsonSerializer.Deserialize<DocumentContent>(root.GetRawText(), options)!,
ContentType.ToolUse => JsonSerializer.Deserialize<ToolUseContent>(root.GetRawText(), options)!,
ContentType.ToolResult => JsonSerializer.Deserialize<ToolResultContent>(root.GetRawText(), options)!,
_ => throw new JsonException($"Unknown content type: {type}")
Expand Down
5 changes: 5 additions & 0 deletions src/AnthropicClient/Models/ContentType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ public static class ContentType
/// Represents the tool result content type.
/// </summary>
public const string ToolResult = "tool_result";

/// <summary>
/// Represents the document content type.
/// </summary>
public const string Document = "document";
}
56 changes: 56 additions & 0 deletions src/AnthropicClient/Models/DocumentContent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Text.Json.Serialization;

using AnthropicClient.Utils;

namespace AnthropicClient.Models;

/// <summary>
/// Represents content from a document that is part of a message.
/// </summary>
public class DocumentContent : Content
{
/// <summary>
/// Gets the source of the document.
/// </summary>
public DocumentSource Source { get; init; } = new();

[JsonConstructor]
internal DocumentContent()
{
}

private void Validate(string mediaType, string data)
{
ArgumentValidator.ThrowIfNull(mediaType, nameof(mediaType));
ArgumentValidator.ThrowIfNull(data, nameof(data));
}

/// <summary>
/// Initializes a new instance of the <see cref="DocumentContent"/> class.
/// </summary>
/// <param name="mediaType">The media type of the document.</param>
/// <param name="data">The data of the document.</param>
/// <exception cref="ArgumentNullException">Thrown when the media type or data is null.</exception>
/// <returns>A new instance of the <see cref="DocumentContent"/> class.</returns>
public DocumentContent(string mediaType, string data) : base(ContentType.Document)
{
Validate(mediaType, data);

Source = new(mediaType, data);
}

/// <summary>
/// Initializes a new instance of the <see cref="DocumentContent"/> class.
/// </summary>
/// <param name="mediaType">The media type of the document.</param>
/// <param name="data">The data of the document.</param>
/// <param name="cacheControl">The cache control to be used for the content.</param>
/// <returns>A new instance of the <see cref="DocumentContent"/> class.</returns>
/// <exception cref="ArgumentNullException">Thrown when the media type, data, or cache control is null.</exception>
public DocumentContent(string mediaType, string data, CacheControl cacheControl) : base(ContentType.Document, cacheControl)
{
Validate(mediaType, data);

Source = new(mediaType, data);
}
}
49 changes: 49 additions & 0 deletions src/AnthropicClient/Models/DocumentSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;

using AnthropicClient.Utils;

namespace AnthropicClient.Models;

/// <summary>
/// Represents a document source.
/// </summary>
public class DocumentSource
{
/// <summary>
/// Gets the media type of the document.
/// </summary>
[JsonPropertyName("media_type")]
public string MediaType { get; init; } = string.Empty;

/// <summary>
/// Gets the data of the document.
/// </summary>
public string Data { get; init; } = string.Empty;

/// <summary>
/// Gets the type of encoding of the document data.
/// </summary>
public string Type { get; init; } = "base64";

[JsonConstructor]
internal DocumentSource()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="DocumentSource"/> class.
/// </summary>
/// <param name="mediaType">The media type of the document.</param>
/// <param name="data">The data of the document.</param>
/// <exception cref="ArgumentException">Thrown when the media type is invalid.</exception>
/// <exception cref="ArgumentNullException">Thrown when the media type or data is null.</exception>
/// <returns>A new instance of the <see cref="DocumentSource"/> class.</returns>
public DocumentSource(string mediaType, string data)
{
ArgumentValidator.ThrowIfNull(mediaType, nameof(mediaType));
ArgumentValidator.ThrowIfNull(data, nameof(data));

MediaType = mediaType;
Data = data;
}
}
77 changes: 77 additions & 0 deletions tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,81 @@ public async Task CreateMessageAsync_WhenToolsContainCacheControl_ItShouldUseCac
resultTwo.Value.Content.Should().NotBeNullOrEmpty();
resultTwo.Value.Usage.CacheReadInputTokens.Should().BeGreaterThan(0);
}

[Fact]
public async Task CreateMessageAsync_WhenProvidedWithPDF_ItShouldReturnResponse()
{
var pdfPath = GetTestFilePath("addendum.pdf");
var bytes = await File.ReadAllBytesAsync(pdfPath);
var base64Data = Convert.ToBase64String(bytes);

var request = new MessageRequest(
model: AnthropicModels.Claude35Sonnet,
messages: [
new(MessageRole.User, [new TextContent("What is the title of this paper?")]),
new(MessageRole.User, [new DocumentContent("application/pdf", base64Data)])
]
);

var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25");
var client = CreateClient(httpClient);

var result = await client.CreateMessageAsync(request);

result.IsSuccess.Should().BeTrue();
result.Value.Should().BeOfType<MessageResponse>();
result.Value.Content.Should().NotBeNullOrEmpty();

var text = result.Value.Content.Aggregate("", (acc, content) =>
{
if (content is TextContent textContent)
{
acc += textContent.Text;
}

return acc;
});

text.Should().Contain("Model Card Addendum: Claude 3.5 Haiku and Upgraded Claude 3.5 Sonnet");
}

[Fact]
public async Task CreateMessageAsync_WhenProvidedWithPDFWithCacheControl_ItShouldUseCache()
{
var pdfPath = GetTestFilePath("addendum.pdf");
var bytes = await File.ReadAllBytesAsync(pdfPath);
var base64Data = Convert.ToBase64String(bytes);

var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("anthropic-beta", "pdfs-2024-09-25, prompt-caching-2024-07-31");
var client = CreateClient(httpClient);

var request = new MessageRequest(
model: AnthropicModels.Claude35Sonnet,
messages: [
new(MessageRole.User, [
new DocumentContent("application/pdf", base64Data, new EphemeralCacheControl()),
new TextContent("What is the title of this paper?")
]),
]
);

var resultOne = await client.CreateMessageAsync(request);

resultOne.IsSuccess.Should().BeTrue();
resultOne.Value.Should().BeOfType<MessageResponse>();
resultOne.Value.Content.Should().NotBeNullOrEmpty();
resultOne.Value.Usage.Should().Match<Usage>(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?")]));

var resultTwo = await client.CreateMessageAsync(request);

resultTwo.IsSuccess.Should().BeTrue();
resultTwo.Value.Should().BeOfType<MessageResponse>();
resultTwo.Value.Content.Should().NotBeNullOrEmpty();
resultTwo.Value.Usage.CacheReadInputTokens.Should().BeGreaterThan(0);
}
}
Binary file added tests/AnthropicClient.Tests/Files/addendum.pdf
Binary file not shown.
59 changes: 59 additions & 0 deletions tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,63 @@ public async Task CreateMessageAsync_WhenCalledMessageIsStreamAndRequestFails_It
)
));
}

[Fact]
public async Task CreateMessageAsync_WhenCalledAndMessageCreatedWithDocumentContent_ItShouldReturnMessage()
{
_mockHttpMessageHandler
.WhenCreateMessageRequest()
.Respond(
HttpStatusCode.OK,
"application/json",
@"{
""content"": [
{
""text"": ""It is a PDF"",
""type"": ""text""
}
],
""id"": ""msg_013Zva2CMHLNnXjNJJKqJ2EF"",
""model"": ""claude-3-5-sonnet-20240620"",
""role"": ""assistant"",
""stop_reason"": ""end_turn"",
""stop_sequence"": null,
""type"": ""message"",
""usage"": {
""input_tokens"": 10,
""output_tokens"": 25
}
}"
);

var request = new MessageRequest(
model: AnthropicModels.Claude35Sonnet,
messages: [
new(MessageRole.User, [new TextContent("What is this?")]),
new(MessageRole.User, [new DocumentContent("application/pdf", "data")])
]
);

var result = await Client.CreateMessageAsync(request);

result.IsSuccess.Should().BeTrue();
result.Value.Should().BeOfType<MessageResponse>();

var message = result.Value;
message.Id.Should().Be("msg_013Zva2CMHLNnXjNJJKqJ2EF");
message.Model.Should().Be("claude-3-5-sonnet-20240620");
message.Role.Should().Be("assistant");
message.StopReason.Should().Be("end_turn");
message.StopSequence.Should().BeNull();
message.Type.Should().Be("message");
message.Usage.InputTokens.Should().Be(10);
message.Usage.OutputTokens.Should().Be(25);
message.Content.Should().HaveCount(1);
message.ToolCall.Should().BeNull();

var textContent = message.Content[0];
textContent.Should().BeOfType<TextContent>();
textContent.As<TextContent>().Text.Should().Be("It is a PDF");
textContent.As<TextContent>().Type.Should().Be("text");
}
}
28 changes: 9 additions & 19 deletions tests/AnthropicClient.Tests/Unit/Models/ContentTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,30 @@ public class ContentTypeTests
[Fact]
public void Text_WhenCalled_ItShouldReturnText()
{
var expected = "text";

var actual = ContentType.Text;

actual.Should().Be(expected);
ContentType.Text.Should().Be("text");
}

[Fact]
public void Image_WhenCalled_ItShouldReturnImage()
{
var expected = "image";

var actual = ContentType.Image;

actual.Should().Be(expected);
ContentType.Image.Should().Be("image");
}

[Fact]
public void ToolUse_WhenCalled_ItShouldReturnToolUse()
{
var expected = "tool_use";

var actual = ContentType.ToolUse;

actual.Should().Be(expected);
ContentType.ToolUse.Should().Be("tool_use");
}

[Fact]
public void ToolResult_WhenCalled_ItShouldReturnToolResult()
{
var expected = "tool_result";

var actual = ContentType.ToolResult;
ContentType.ToolResult.Should().Be("tool_result");
}

actual.Should().Be(expected);
[Fact]
public void Document_WhenCalled_ItShouldReturnDocument()
{
ContentType.Document.Should().Be("document");
}
}
Loading