From 085e405ffdffb4e8e040ea1b7c7a73d633cd1bf1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 3 Jun 2026 06:51:11 +0000 Subject: [PATCH 1/3] fix(providers/anthropic): forward PDF filename to document title Anthropic's DocumentBlockParam exposes a Title field that the model uses when it refers back to an attached document. Forward FilePart.Filename into that field so users can ask the model about a document by name. The title is sanitized first: Anthropic restricts titles to alphanumerics, whitespace, hyphens, parentheses, and square brackets, and returns 'The document file name can only contain alphanumeric characters, whitespace characters, hyphens, parentheses, and square brackets.' for any title containing other runes. Disallowed runes are replaced with spaces, runs of whitespace are collapsed, and the result is trimmed. Empty or fully disallowed input falls back to 'Document' so every attached document has a stable handle, matching the invariant the OpenAI provider already enforces with its part-N.pdf synthetic name. The sanitizer is a Go port of the implementation in coder/mux (src/node/utils/messages/sanitizeAnthropicDocumentFilename.ts); prior art for sending filename as title also includes vercel/ai's @ai-sdk/anthropic, which sets document.title from part.filename when no provider-options title is supplied. --- providers/anthropic/anthropic.go | 1 + providers/anthropic/anthropic_test.go | 31 ++++++++++ providers/anthropic/sanitize.go | 33 +++++++++++ providers/anthropic/sanitize_test.go | 81 +++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 providers/anthropic/sanitize.go create mode 100644 providers/anthropic/sanitize_test.go diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index badbb4bd4..4bad3d221 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() } diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 14781a3f2..b1c8d4ec8 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) }) 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)) + }) + } +} From 492b6b0acdbd973c2813ae585dc1544577b97fcd Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 3 Jun 2026 06:51:57 +0000 Subject: [PATCH 2/3] fix(providers/anthropic): forward text filename and warn on unsupported media types Mirror the PDF document-title handling on the text/* document branch so text attachments also reach Anthropic with a stable handle the model can refer back to. The filename runs through the same sanitizer; an empty or fully disallowed filename falls back to 'Document'. Also add a default case to the file MediaType switch that emits a CallWarning when a FilePart's media type is not handled. Previously the Anthropic provider silently dropped any file with a media type other than image/*, application/pdf, or text/*, so unsupported attachments left no trace for the caller. The new behavior matches the openai, openaicompat, openrouter, and vercel providers, which already warn on unsupported FilePart media types. --- providers/anthropic/anthropic.go | 6 +++ providers/anthropic/anthropic_test.go | 67 +++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 4bad3d221..6c2bc38f2 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -949,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 b1c8d4ec8..d2a8a83d7 100644 --- a/providers/anthropic/anthropic_test.go +++ b/providers/anthropic/anthropic_test.go @@ -324,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", }, @@ -335,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() @@ -357,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) { From e96f6f77fa7b77007889cb1cf02b298eaa1e7de1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 4 Jun 2026 05:16:35 +0000 Subject: [PATCH 3/3] chore(providers/openai): satisfy revive exported-const doc format revive's exported rule requires doc comments on exported consts to begin with the const name. Two consts in computer_use.go are flagged on coder_2_33 and on this PR's lint job. Rewrite the comments to satisfy revive without changing behavior. --- providers/openai/computer_use.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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