diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index badbb4bd4..6c2bc38f2 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -940,6 +940,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl docBlock := anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{ Data: base64Encoded, }) + docBlock.OfDocument.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename)) if cacheControl != nil { docBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam() } @@ -948,10 +949,16 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl documentBlock := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{ Data: string(file.Data), }) + documentBlock.OfDocument.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename)) if cacheControl != nil { documentBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam() } anthropicContent = append(anthropicContent, documentBlock) + default: + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: fmt.Sprintf("file part media type %s not supported", file.MediaType), + }) } } } diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 14781a3f2..d2a8a83d7 100644 --- a/providers/anthropic/anthropic_test.go +++ b/providers/anthropic/anthropic_test.go @@ -271,6 +271,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ fantasy.FilePart{ + Filename: "quarterly_report.v1.pdf", Data: []byte("fake pdf data"), MediaType: "application/pdf", }, @@ -282,6 +283,36 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { require.Empty(t, systemBlocks) require.Len(t, messages, 1) + require.Len(t, messages[0].Content, 1) + require.NotNil(t, messages[0].Content[0].OfDocument) + require.Equal(t, "quarterly report v1 pdf", messages[0].Content[0].OfDocument.Title.Value) + require.True(t, messages[0].Content[0].OfDocument.Title.Valid()) + require.Empty(t, warnings) + }) + + t.Run("should fall back to Document title when PDF filename is missing", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.FilePart{ + Data: []byte("fake pdf data"), + MediaType: "application/pdf", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Len(t, messages[0].Content, 1) + require.NotNil(t, messages[0].Content[0].OfDocument) + require.Equal(t, "Document", messages[0].Content[0].OfDocument.Title.Value) + require.True(t, messages[0].Content[0].OfDocument.Title.Valid()) require.Empty(t, warnings) }) @@ -293,6 +324,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ fantasy.FilePart{ + Filename: "notes_v1.md", Data: []byte("# Hello World\nSome markdown content"), MediaType: "text/markdown", }, @@ -304,9 +336,66 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { require.Empty(t, systemBlocks) require.Len(t, messages, 1) + require.Len(t, messages[0].Content, 1) + require.NotNil(t, messages[0].Content[0].OfDocument) + require.Equal(t, "notes v1 md", messages[0].Content[0].OfDocument.Title.Value) + require.True(t, messages[0].Content[0].OfDocument.Title.Valid()) require.Empty(t, warnings) }) + t.Run("should fall back to Document title when text filename is missing", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.FilePart{ + Data: []byte("# Hello World\nSome markdown content"), + MediaType: "text/markdown", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Len(t, messages[0].Content, 1) + require.NotNil(t, messages[0].Content[0].OfDocument) + require.Equal(t, "Document", messages[0].Content[0].OfDocument.Title.Value) + require.True(t, messages[0].Content[0].OfDocument.Title.Valid()) + require.Empty(t, warnings) + }) + + t.Run("should warn on unsupported file media type while keeping visible content", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "look at this archive"}, + fantasy.FilePart{ + Filename: "logs.zip", + Data: []byte("not supported"), + MediaType: "application/zip", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Len(t, warnings, 1) + require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type) + require.Contains(t, warnings[0].Message, "application/zip") + require.Contains(t, warnings[0].Message, "not supported") + }) + t.Run("should drop user messages without visible content", func(t *testing.T) { t.Parallel() @@ -326,10 +415,13 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { require.Empty(t, systemBlocks) require.Empty(t, messages) - require.Len(t, warnings, 1) + require.Len(t, warnings, 2) require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type) - require.Contains(t, warnings[0].Message, "dropping empty user message") - require.Contains(t, warnings[0].Message, "neither user-facing content nor tool results") + require.Contains(t, warnings[0].Message, "application/zip") + require.Contains(t, warnings[0].Message, "not supported") + require.Equal(t, fantasy.CallWarningTypeOther, warnings[1].Type) + require.Contains(t, warnings[1].Message, "dropping empty user message") + require.Contains(t, warnings[1].Message, "neither user-facing content nor tool results") }) t.Run("should keep user messages with tool results", func(t *testing.T) { diff --git a/providers/anthropic/sanitize.go b/providers/anthropic/sanitize.go new file mode 100644 index 000000000..f9b664219 --- /dev/null +++ b/providers/anthropic/sanitize.go @@ -0,0 +1,33 @@ +package anthropic + +import ( + "regexp" + "strings" +) + +// anthropicDocumentTitleDisallowed matches every rune that Anthropic's +// document title field rejects. The allowlist is alphanumerics, whitespace, +// hyphens, parentheses, and square brackets. Anything else is replaced +// with a space; consecutive whitespace is then collapsed. +// +// Anthropic returns "The document file name can only contain alphanumeric +// characters, whitespace characters, hyphens, parentheses, and square +// brackets." when the title falls outside this set. +var anthropicDocumentTitleDisallowed = regexp.MustCompile(`[^a-zA-Z0-9\s\-()\[\]]`) + +// anthropicDocumentTitleWhitespace collapses runs of whitespace. +var anthropicDocumentTitleWhitespace = regexp.MustCompile(`\s+`) + +// sanitizeAnthropicDocumentTitle adapts a filename for use as the title +// field on an Anthropic DocumentBlock. Disallowed characters are replaced +// with spaces, runs of whitespace are collapsed, and the result is trimmed. +// Empty input (or input that sanitizes to empty) returns "Document" so the +// model always has a stable handle for the attachment. +func sanitizeAnthropicDocumentTitle(filename string) string { + replaced := anthropicDocumentTitleDisallowed.ReplaceAllString(filename, " ") + collapsed := strings.TrimSpace(anthropicDocumentTitleWhitespace.ReplaceAllString(replaced, " ")) + if collapsed == "" { + return "Document" + } + return collapsed +} diff --git a/providers/anthropic/sanitize_test.go b/providers/anthropic/sanitize_test.go new file mode 100644 index 000000000..6d99a94c6 --- /dev/null +++ b/providers/anthropic/sanitize_test.go @@ -0,0 +1,81 @@ +package anthropic + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeAnthropicDocumentTitle(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + want string + }{ + { + name: "empty falls back to Document", + input: "", + want: "Document", + }, + { + name: "all disallowed falls back to Document", + input: "...", + want: "Document", + }, + { + name: "whitespace only falls back to Document", + input: " \t\n", + want: "Document", + }, + { + name: "alphanumeric is preserved", + input: "report 2026", + want: "report 2026", + }, + { + name: "dots and underscores become spaces", + input: "quarterly_report.v1.pdf", + want: "quarterly report v1 pdf", + }, + { + name: "preserves hyphens, parentheses, square brackets", + input: "draft-1 (final) [v2].pdf", + want: "draft-1 (final) [v2] pdf", + }, + { + name: "collapses runs of whitespace", + input: "name with spaces", + want: "name with spaces", + }, + { + name: "trims leading and trailing whitespace", + input: " leading and trailing ", + want: "leading and trailing", + }, + { + name: "leading dots collapse to single space then trim", + input: "..hidden.txt", + want: "hidden txt", + }, + { + name: "non-ascii letters are not allowlisted", + input: "résumé.pdf", + want: "r sum pdf", + }, + { + name: "production failure example is sanitized", + input: "D19910350Lj.pdf", + want: "D19910350Lj pdf", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, sanitizeAnthropicDocumentTitle(tc.input)) + }) + } +} diff --git a/providers/openai/computer_use.go b/providers/openai/computer_use.go index a5a871f7c..4d398ff75 100644 --- a/providers/openai/computer_use.go +++ b/providers/openai/computer_use.go @@ -11,12 +11,12 @@ import ( const computerUseToolID = "openai.computer_use" -// Type identifier for computer use metadata, registered in -// responses_options.go init(). +// TypeComputerUseMetadata is the type identifier for computer use metadata, +// registered in responses_options.go init(). const TypeComputerUseMetadata = Name + ".responses.computer_use_metadata" -// Type identifier for computer call output options, registered in -// responses_options.go init(). +// TypeComputerCallOutputOptions is the type identifier for computer call +// output options, registered in responses_options.go init(). const TypeComputerCallOutputOptions = Name + ".responses.computer_call_output_options" // ComputerUseMetadata stores the raw wire-format JSON of a computer_call