diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index d4aff6d7..73079ad4 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -9,6 +9,7 @@ import ( "fmt" "iter" "maps" + "mime" "reflect" "slices" "strings" @@ -482,6 +483,17 @@ func (a *client) buildMessageParams(messages []*message.Message, opts []agent.Op return params, nil } +// isPDFMediaType reports whether mediaType denotes a PDF document. Media types +// may carry parameters (e.g. "application/pdf; charset=binary") and arbitrary +// casing, so the base type is parsed and compared case-insensitively. +func isPDFMediaType(mediaType string) bool { + base, _, err := mime.ParseMediaType(mediaType) + if err != nil { + base = strings.ToLower(strings.TrimSpace(mediaType)) + } + return base == "application/pdf" +} + // buildWebSearchTool maps a hosted WebSearch tool to the Anthropic // web_search_20250305 tool request, populating the optional MaxUses, // AllowedDomains, BlockedDomains and UserLocation fields from the tool's @@ -632,13 +644,32 @@ func buildMessageParam(msg *message.Message) (anthropic.MessageParam, error) { } content = append(content, anthropic.NewToolResultBlock(c.CallID, resStr, c.Error != nil)) case *message.DataContent: - if c.TopLevelMediaType() == "image" { + switch { + case c.TopLevelMediaType() == "image": mediaType := c.MediaType if mediaType == "" { mediaType = "image/jpeg" } content = append(content, anthropic.NewImageBlockBase64(mediaType, c.Data)) + case isPDFMediaType(c.MediaType): + content = append(content, anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{ + Data: c.Data, + })) + } + case *message.URIContent: + switch { + case c.TopLevelMediaType() == "image": + content = append(content, anthropic.NewImageBlock(anthropic.URLImageSourceParam{URL: c.URI})) + case isPDFMediaType(c.MediaType): + content = append(content, anthropic.NewDocumentBlock(anthropic.URLPDFSourceParam{URL: c.URI})) } + case *message.HostedFileContent: + // The stable Anthropic Messages API used here (anthropic.MessageNewParams) + // has no file-id image/document source in anthropic-sdk-go v1.58.1; only + // the Beta API exposes BetaFileImageSourceParam/BetaFileDocumentSourceParam. + // A hosted file reference therefore cannot be forwarded yet, so surface an + // explicit error rather than silently dropping it. + return anthropic.MessageParam{}, fmt.Errorf("anthropic: hosted file references (file id %q) are not supported by the Messages API; use DataContent or URIContent instead", c.FileID) } } diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index 049c8614..8ba976ed 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -847,6 +847,172 @@ func TestToolUseEmptyArgumentsSerializeAsObject(t *testing.T) { } } +// A URIContent image URL and a DataContent application/pdf must be forwarded to +// Anthropic as an image block with a URL source and a document block. Before the +// fix these inputs fell through the content switch and were silently dropped, +// diverging from the OpenAI chat provider which maps all three multimodal inputs. +func TestBuildMessageParam_ImageURLAndPDFAreForwarded(t *testing.T) { + bodyCh := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + bodyCh <- body + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, minimalMessageResponse("ok")) + })) + defer server.Close() + + a := newTestClient(t, server) + + msgs := []*message.Message{ + {Role: message.RoleUser, Contents: message.Contents{ + &message.URIContent{URI: "https://example.com/cat.png", MediaType: "image/png"}, + &message.DataContent{Data: "JVBERi0xLjQK", MediaType: "application/pdf"}, + }}, + } + if _, err := a.Run(t.Context(), msgs).Collect(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var req map[string]any + if err := json.Unmarshal(<-bodyCh, &req); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + messages, ok := req["messages"].([]any) + if !ok { + t.Fatalf("request messages = %#v, want a JSON array", req["messages"]) + } + + var imageURL, documentBase64 bool + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + blocks, ok := msg["content"].([]any) + if !ok { + continue + } + for _, b := range blocks { + block, ok := b.(map[string]any) + if !ok { + continue + } + source, _ := block["source"].(map[string]any) + switch block["type"] { + case "image": + if source["type"] == "url" && source["url"] == "https://example.com/cat.png" { + imageURL = true + } + case "document": + if source["type"] == "base64" && source["media_type"] == "application/pdf" && source["data"] == "JVBERi0xLjQK" { + documentBase64 = true + } + } + } + } + if !imageURL { + t.Error("image block with a URL source not found in request") + } + if !documentBase64 { + t.Error("document block with a base64 application/pdf source not found in request") + } +} + +// A PDF media type that carries parameters or non-canonical casing (e.g. +// "application/PDF; charset=binary") must still be recognized and forwarded as a +// document block, not dropped. +func TestBuildMessageParam_PDFMediaTypeWithParametersIsForwarded(t *testing.T) { + bodyCh := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + bodyCh <- body + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, minimalMessageResponse("ok")) + })) + defer server.Close() + + a := newTestClient(t, server) + + msgs := []*message.Message{ + {Role: message.RoleUser, Contents: message.Contents{ + &message.DataContent{Data: "JVBERi0xLjQK", MediaType: "application/PDF; charset=binary"}, + }}, + } + if _, err := a.Run(t.Context(), msgs).Collect(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var req map[string]any + if err := json.Unmarshal(<-bodyCh, &req); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + messages, ok := req["messages"].([]any) + if !ok { + t.Fatalf("request messages = %#v, want a JSON array", req["messages"]) + } + + var documentBase64 bool + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + blocks, ok := msg["content"].([]any) + if !ok { + continue + } + for _, b := range blocks { + block, ok := b.(map[string]any) + if !ok { + continue + } + source, _ := block["source"].(map[string]any) + if block["type"] == "document" && source["type"] == "base64" && source["data"] == "JVBERi0xLjQK" { + documentBase64 = true + } + } + } + if !documentBase64 { + t.Error("document block for a PDF media type with parameters not found in request") + } +} + +// A HostedFileContent cannot be represented by the stable Messages API, so the +// request must fail with an explicit error rather than silently dropping it. +func TestBuildMessageParam_HostedFileContentReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("request should not be sent when a hosted file reference is present") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, minimalMessageResponse("ok")) + })) + defer server.Close() + + a := newTestClient(t, server) + + msgs := []*message.Message{ + {Role: message.RoleUser, Contents: message.Contents{ + &message.HostedFileContent{FileID: "file_123"}, + }}, + } + _, err := a.Run(t.Context(), msgs).Collect() + if err == nil { + t.Fatal("expected an error for a hosted file reference, got nil") + } + if !strings.Contains(err.Error(), "file_123") { + t.Errorf("error = %v, want it to mention the offending file id", err) + } +} + // A hosted WebSearch tool must be mapped to the Anthropic web_search_20250305 // tool request, with MaxUses/AllowedDomains/UserLocation carried across from // AdditionalProperties. This mirrors the OpenAI providers and the Python