diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 0c2cebfbad..1d9b43c4ff 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -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) diff --git a/go.mod b/go.mod index d4ea36cb27..241ab475c0 100644 --- a/go.mod +++ b/go.mod @@ -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. diff --git a/go.sum b/go.sum index 5840cb7bf5..61bfd608f8 100644 --- a/go.sum +++ b/go.sum @@ -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=