fix: forward user-uploaded PDFs to Anthropic and Bedrock (#25946)

Previously, user-uploaded PDFs were silently dropped by fantasy's
Anthropic provider adapter, so Claude (direct or via Bedrock) only saw
the user's text and replied as if no document had been attached. Other
providers (OpenAI, Gemini, OpenRouter, Vercel) were unaffected.

Bumps `coder/fantasy` past
[coder/fantasy#37](https://github.com/coder/fantasy/pull/37)
(cherry-pick of upstream
[charmbracelet/fantasy#197](https://github.com/charmbracelet/fantasy/pull/197)),
which emits an Anthropic `document` content block with a base64 PDF
source for `fantasy.FilePart{MediaType: "application/pdf"}` and counts
`OfDocument` as user-visible so a PDF-only user message is no longer
culled as empty.

Adds a regression test
(`TestModelFromConfig_AnthropicPDFFilePartReachesProvider`) that drives
a `fantasy.FilePart` through the real Anthropic provider against a
`chattest.NewAnthropic` stub and asserts the outbound request contains a
base64 document block. The test was verified to fail on the previous
fantasy pin (the request leaves with zero messages and `Generate`
returns EOF) and pass on the new one.

Manually verified end-to-end with `./scripts/develop.sh`: uploading a
PDF to a Claude-backed Coder Agents chat now lets the model read it.

Closes CODAGT-540
This commit is contained in:
Ethan
2026-06-03 00:16:01 +10:00
committed by GitHub
parent d2697dc5b0
commit 9fe75587ae
3 changed files with 82 additions and 4 deletions
@@ -1304,6 +1304,80 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) {
})
}
// TestModelFromConfig_AnthropicPDFFilePartReachesProvider pins the end-to-end
// path that lets a user-uploaded PDF actually reach Claude/Bedrock: a
// fantasy.FilePart with MediaType "application/pdf" must be serialized as an
// Anthropic "document" content block with a base64 source carrying the PDF
// bytes. Older fantasy versions silently dropped PDF FileParts in the
// Anthropic provider, so the user message ended up empty and the model never
// saw the document. See coder/fantasy#37 (cherry-pick of upstream
// charmbracelet/fantasy#197). The Generate call would fail outright on the
// regressed code path because the dropped FilePart leaves the request with
// zero messages.
func TestModelFromConfig_AnthropicPDFFilePartReachesProvider(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
pdfData := []byte("%PDF-1.7\nfake pdf bytes for regression test")
wantData := base64.StdEncoding.EncodeToString(pdfData)
called := make(chan struct{})
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
defer close(called)
require.Len(t, req.Messages, 1, "PDF FilePart should produce one Anthropic message, not be dropped as empty")
require.Equal(t, "user", req.Messages[0].Role)
var blocks []struct {
Type string `json:"type"`
Source struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
} `json:"source"`
}
require.NoError(t, json.Unmarshal(req.Messages[0].Content, &blocks),
"user content should be a structured block array, got: %s", string(req.Messages[0].Content))
var found bool
for _, block := range blocks {
if block.Type != "document" {
continue
}
assert.Equal(t, "base64", block.Source.Type, "PDF document block must use a base64 source")
assert.Equal(t, wantData, block.Source.Data, "PDF bytes must round-trip base64 unchanged")
if block.Source.MediaType != "" {
assert.Equal(t, "application/pdf", block.Source.MediaType)
}
found = true
}
require.True(t, found, "expected an Anthropic document block carrying the PDF, got: %s", string(req.Messages[0].Content))
return chattest.AnthropicNonStreamingResponse("ok")
})
keys := chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{"anthropic": "test-key"},
BaseURLByProvider: map[string]string{"anthropic": serverURL},
}
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), nil, nil)
require.NoError(t, err)
_, err = model.Generate(ctx, fantasy.Call{
Prompt: []fantasy.Message{
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.FilePart{Data: pdfData, MediaType: "application/pdf"},
},
},
},
})
require.NoError(t, err)
_ = testutil.TryReceive(ctx, t, called)
}
func TestModelFromConfig_NilExtraHeaders(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
+6 -2
View File
@@ -90,8 +90,12 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713
// streams close before their terminal events.
// 9) coder/fantasy#35, preserve Anthropic replay fidelity for signed
// reasoning and provider-executed web_search error results.
// See: https://github.com/coder/fantasy/commits/cfca5fd82c5dd
replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d
// 10) coder/fantasy#37, cherry-pick of upstream charmbracelet/fantasy#197:
// emit a Base64 PDF document block for application/pdf FileParts on the
// Anthropic provider so user-uploaded PDFs actually reach Claude/Bedrock
// instead of being silently dropped.
// See: https://github.com/coder/fantasy/commits/7d46e640327a
replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260602023814-7d46e640327a
// coder/coder uses a fork of charmbracelet's fork of the Anthropic Go SDK
// with performance improvements and Bedrock header cleanup.
+2 -2
View File
@@ -324,8 +324,8 @@ github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwu
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4=
github.com/coder/clistat v1.2.1 h1:P9/10njXMyj5cWzIU5wkRsSy5LVQH49+tcGMsAgWX0w=
github.com/coder/clistat v1.2.1/go.mod h1:m7SC0uj88eEERgvF8Kn6+w6XF21BeSr+15f7GoLAw0A=
github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d h1:CS3b2CZUDdHMwwtDoAtZF2/dzZd57yJtSJi3t86pmxE=
github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d/go.mod h1:wZ0e3lEPqrM0XiIdAUQLvMKCLYhc3gi96MRX2wjbX44=
github.com/coder/fantasy v0.0.0-20260602023814-7d46e640327a h1:ffQixHAwjJLHgFfe4rtrAsFNRGhEyWnBSpInnLIxDPo=
github.com/coder/fantasy v0.0.0-20260602023814-7d46e640327a/go.mod h1:wZ0e3lEPqrM0XiIdAUQLvMKCLYhc3gi96MRX2wjbX44=
github.com/coder/flog v1.1.0 h1:kbAes1ai8fIS5OeV+QAnKBQE22ty1jRF/mcAwHpLBa4=
github.com/coder/flog v1.1.0/go.mod h1:UQlQvrkJBvnRGo69Le8E24Tcl5SJleAAR7gYEHzAmdQ=
github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322 h1:m0lPZjlQ7vdVpRBPKfYIFlmgevoTkBxB10wv6l2gOaU=