Skip to content
Open
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
33 changes: 32 additions & 1 deletion provider/anthropicprovider/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"iter"
"maps"
"mime"
"reflect"
"slices"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
166 changes: 166 additions & 0 deletions provider/anthropicprovider/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading