From b08142e27501aa507dc31cc88e62184a985ecef9 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 2 Jun 2026 02:05:23 +0000 Subject: [PATCH] feat(providers/anthropic): add PDF document support for file content blocks Cherry-picks charmbracelet/fantasy#197 (commit 95dcd6e) onto coder_2_33. Previously, fantasy.FilePart parts with MediaType "application/pdf" were silently dropped from the Anthropic content list, so a user message whose only content was a PDF attachment produced zero Anthropic messages and a 'dropping empty user message' warning. The model never saw the document. Add an explicit application/pdf case that emits a NewDocumentBlock with a Base64PDFSourceParam, mirroring the image branch's cache-control wiring, and also wire cache-control into the existing text/* document branch. Treat OfDocument as user-visible content so PDF-only user messages are no longer culled as empty. Co-authored-by: Nic-vdwalt <36562088+Nic-vdwalt@users.noreply.github.com> --- providers/anthropic/anthropic.go | 15 +++++++-- providers/anthropic/anthropic_test.go | 46 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 4be919e09..badbb4bd4 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -927,7 +927,6 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl if !ok { continue } - // TODO: handle other file types switch { case strings.HasPrefix(file.MediaType, "image/"): base64Encoded := base64.StdEncoding.EncodeToString(file.Data) @@ -936,10 +935,22 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl imageBlock.OfImage.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, imageBlock) + case file.MediaType == "application/pdf": + base64Encoded := base64.StdEncoding.EncodeToString(file.Data) + docBlock := anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{ + Data: base64Encoded, + }) + if cacheControl != nil { + docBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam() + } + anthropicContent = append(anthropicContent, docBlock) case strings.HasPrefix(file.MediaType, "text/"): documentBlock := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{ Data: string(file.Data), }) + if cacheControl != nil { + documentBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam() + } anthropicContent = append(anthropicContent, documentBlock) } } @@ -1135,7 +1146,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl func hasVisibleUserContent(content []anthropic.ContentBlockParamUnion) bool { for _, block := range content { - if block.OfText != nil || block.OfImage != nil || block.OfToolResult != nil { + if block.OfText != nil || block.OfImage != nil || block.OfDocument != nil || block.OfToolResult != nil { return true } } diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 176b4f276..14781a3f2 100644 --- a/providers/anthropic/anthropic_test.go +++ b/providers/anthropic/anthropic_test.go @@ -263,6 +263,50 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { require.Empty(t, warnings) }) + t.Run("should keep user messages with PDF content", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.FilePart{ + Data: []byte("fake pdf data"), + MediaType: "application/pdf", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Empty(t, warnings) + }) + + t.Run("should keep user messages with text document content", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.FilePart{ + Data: []byte("# Hello World\nSome markdown content"), + MediaType: "text/markdown", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Empty(t, warnings) + }) + t.Run("should drop user messages without visible content", func(t *testing.T) { t.Parallel() @@ -272,7 +316,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { Content: []fantasy.MessagePart{ fantasy.FilePart{ Data: []byte("not supported"), - MediaType: "application/pdf", + MediaType: "application/zip", }, }, },