diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index 62eb663d..76b1c8ab 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -5,6 +5,7 @@ package anthropicprovider import ( "cmp" "context" + "encoding/base64" "encoding/json" "fmt" "iter" @@ -691,6 +692,28 @@ func buildMessageParam(msg *message.Message) (anthropic.MessageParam, error) { } case *message.URIContent: switch { + case strings.HasPrefix(strings.ToLower(c.URI), "data:"): + // A data: URI carries the bytes inline. Anthropic's URL image/PDF + // sources require an external http(s) reference, so a data: URI sent + // as a url source is rejected; decode it and send a base64 block + // instead, mirroring the DataContent branch and the Gemini/OpenAI + // data: handling. + data, mediaType, err := message.DecodeDataURI(c.URI) + if err != nil { + return anthropic.MessageParam{}, fmt.Errorf("anthropicprovider: failed to decode data URI content: %w", err) + } + // An explicit URIContent.MediaType overrides the one parsed from the + // data: URI, matching the gemini provider. + if c.MediaType != "" { + mediaType = c.MediaType + } + encoded := base64.StdEncoding.EncodeToString(data) + switch { + case strings.HasPrefix(strings.ToLower(mediaType), "image/"): + content = append(content, anthropic.NewImageBlockBase64(mediaType, encoded)) + case isPDFMediaType(mediaType): + content = append(content, anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{Data: encoded})) + } case c.TopLevelMediaType() == "image": content = append(content, anthropic.NewImageBlock(anthropic.URLImageSourceParam{URL: c.URI})) case isPDFMediaType(c.MediaType): diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index 2f2dd194..62ffe175 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -1515,3 +1515,54 @@ func TestStreamingClosesResponseBody(t *testing.T) { t.Fatal("streaming response body was not closed after early consumer exit") } } + +// A URIContent carrying a data: URI must be decoded and forwarded to Anthropic +// as an inline base64 block, not passed through as a URL source (which the API +// rejects). Mirrors the DataContent branch and the Gemini/OpenAI data: handling. +func TestBuildMessageParam_DataURIImageForwardedAsBase64(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: "data:image/png;base64,aGVsbG8=", MediaType: "image/png"}, + }}, + } + if _, err := a.Run(t.Context(), msgs).Collect(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := <-bodyCh + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + messages, _ := req["messages"].([]any) + var base64Image bool + for _, m := range messages { + msg, _ := m.(map[string]any) + blocks, _ := msg["content"].([]any) + for _, b := range blocks { + block, _ := b.(map[string]any) + source, _ := block["source"].(map[string]any) + if block["type"] == "image" && source["type"] == "base64" && source["media_type"] == "image/png" && source["data"] == "aGVsbG8=" { + base64Image = true + } + } + } + if !base64Image { + t.Fatalf("expected data: URIContent image forwarded as a base64 image source, got: %s", body) + } +}