Skip to content
Merged
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
7 changes: 7 additions & 0 deletions providers/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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),
})
}
}
}
Expand Down
98 changes: 95 additions & 3 deletions providers/anthropic/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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)
})

Expand All @@ -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",
},
Expand All @@ -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()

Expand All @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions providers/anthropic/sanitize.go
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 81 additions & 0 deletions providers/anthropic/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
8 changes: 4 additions & 4 deletions providers/openai/computer_use.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading