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
45 changes: 45 additions & 0 deletions core/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -4650,6 +4650,21 @@ func convertAnthropicContentBlocksToResponsesMessagesGrouped(contentBlocks []Ant
bifrostMessages = append(bifrostMessages, bifrostMsg)
}

case AnthropicContentBlockTypeContainerUpload:
if block.FileID != nil {
bifrostMsg := schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: role,
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{block.toBifrostResponsesContainerUploadBlock()},
},
}
if isOutputMessage {
bifrostMsg.ID = schemas.Ptr("msg_" + providerUtils.GetRandomString(50))
}
bifrostMessages = append(bifrostMessages, bifrostMsg)
}

case AnthropicContentBlockTypeThinking:
if block.Thinking != nil {
bifrostMsg := schemas.ResponsesMessage{
Expand Down Expand Up @@ -4965,6 +4980,20 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex
}
bifrostMessages = append(bifrostMessages, bifrostMsg)
}
case AnthropicContentBlockTypeContainerUpload:
if block.FileID != nil {
bifrostMsg := schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: role,
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{block.toBifrostResponsesContainerUploadBlock()},
},
}
if isOutputMessage {
bifrostMsg.ID = schemas.Ptr("msg_" + providerUtils.GetRandomString(50))
}
bifrostMessages = append(bifrostMessages, bifrostMsg)
}
case AnthropicContentBlockTypeThinking:
if block.Thinking != nil {
// Collect reasoning blocks to create a single reasoning message
Expand Down Expand Up @@ -7124,6 +7153,14 @@ func convertContentBlockToAnthropic(block schemas.ResponsesMessageContentBlock)
)
return &anthropicBlock
}
case schemas.ResponsesInputMessageContentBlockTypeContainer:
if block.FileID != nil {
return &AnthropicContentBlock{
Type: AnthropicContentBlockTypeContainerUpload,
FileID: block.FileID,
CacheControl: block.CacheControl,
}
}
case schemas.ResponsesOutputMessageContentTypeReasoning:
if block.Text != nil {
return &AnthropicContentBlock{
Expand Down Expand Up @@ -7163,6 +7200,14 @@ func (block AnthropicContentBlock) toBifrostResponsesImageBlock() schemas.Respon
}
}

func (block AnthropicContentBlock) toBifrostResponsesContainerUploadBlock() schemas.ResponsesMessageContentBlock {
return schemas.ResponsesMessageContentBlock{
Type: schemas.ResponsesInputMessageContentBlockTypeContainer,
FileID: block.FileID,
CacheControl: block.CacheControl,
}
}

func (block AnthropicContentBlock) toBifrostResponsesDocumentBlock() schemas.ResponsesMessageContentBlock {
resultBlock := schemas.ResponsesMessageContentBlock{
Type: schemas.ResponsesInputMessageContentBlockTypeFile,
Expand Down
79 changes: 79 additions & 0 deletions core/providers/anthropic/roundtrip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,82 @@ func TestRoundTrip_ContentBlocks_Bedrock(t *testing.T) {
}
}
}

// --- container_upload -------------------------------------------------------

// findContainerUploadBlocks collects every container_upload block across a
// message slice.
func findContainerUploadBlocks(msgs []AnthropicMessage) []AnthropicContentBlock {
var out []AnthropicContentBlock
for _, m := range msgs {
for _, b := range m.Content.ContentBlocks {
if b.Type == AnthropicContentBlockTypeContainerUpload {
out = append(out, b)
}
}
}
return out
}

// container_upload (file staged into the code-execution container) must survive
// Anthropic → Bifrost → Anthropic, carrying file_id and cache_control.
func TestRoundTrip_ContainerUpload(t *testing.T) {
fileID := "file_011CcpBQA2BV1gthmNPSYzkh"
messages := []AnthropicMessage{
{
Role: AnthropicMessageRoleUser,
Content: AnthropicContent{
ContentBlocks: []AnthropicContentBlock{
{Type: AnthropicContentBlockTypeText, Text: schemas.Ptr("analyse and share model names")},
{
Type: AnthropicContentBlockTypeContainerUpload,
FileID: &fileID,
CacheControl: &schemas.CacheControl{Type: schemas.CacheControlTypeEphemeral},
},
},
},
},
}

outMsgs, _ := roundTrip(t, messages, nil, schemas.Anthropic, "claude-sonnet-4-6")

blocks := findContainerUploadBlocks(outMsgs)
if len(blocks) != 1 {
t.Fatalf("container_upload blocks = %d, want 1", len(blocks))
}
if blocks[0].FileID == nil || *blocks[0].FileID != fileID {
t.Errorf("file_id = %v, want %q", blocks[0].FileID, fileID)
}
if blocks[0].CacheControl == nil || blocks[0].CacheControl.Type != schemas.CacheControlTypeEphemeral {
t.Errorf("cache_control = %v, want ephemeral", blocks[0].CacheControl)
}
}

// The grouped converter (used for Bedrock-routed Anthropic models) must also
// emit a neutral container_upload block rather than dropping it.
func TestRoundTrip_ContainerUpload_Grouped(t *testing.T) {
fileID := "file_grouped_123"
role := schemas.ResponsesInputMessageRoleUser
msgs := convertAnthropicContentBlocksToResponsesMessagesGrouped(
[]AnthropicContentBlock{{Type: AnthropicContentBlockTypeContainerUpload, FileID: &fileID}},
&role, false,
)

var found *schemas.ResponsesMessageContentBlock
for i := range msgs {
if msgs[i].Content == nil {
continue
}
for j := range msgs[i].Content.ContentBlocks {
if msgs[i].Content.ContentBlocks[j].Type == schemas.ResponsesInputMessageContentBlockTypeContainer {
found = &msgs[i].Content.ContentBlocks[j]
}
}
}
if found == nil {
t.Fatalf("grouped converter dropped container_upload block")
}
if found.FileID == nil || *found.FileID != fileID {
t.Errorf("file_id = %v, want %q", found.FileID, fileID)
}
}
10 changes: 6 additions & 4 deletions core/schemas/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -1228,10 +1228,12 @@ func (rc *ResponsesMessageContent) UnmarshalJSON(data []byte) error {
type ResponsesMessageContentBlockType string

const (
ResponsesInputMessageContentBlockTypeText ResponsesMessageContentBlockType = "input_text"
ResponsesInputMessageContentBlockTypeImage ResponsesMessageContentBlockType = "input_image"
ResponsesInputMessageContentBlockTypeFile ResponsesMessageContentBlockType = "input_file"
ResponsesInputMessageContentBlockTypeAudio ResponsesMessageContentBlockType = "input_audio"
ResponsesInputMessageContentBlockTypeText ResponsesMessageContentBlockType = "input_text"
ResponsesInputMessageContentBlockTypeImage ResponsesMessageContentBlockType = "input_image"
ResponsesInputMessageContentBlockTypeFile ResponsesMessageContentBlockType = "input_file"
ResponsesInputMessageContentBlockTypeAudio ResponsesMessageContentBlockType = "input_audio"
ResponsesInputMessageContentBlockTypeContainer ResponsesMessageContentBlockType = "input_container" // Anthropic-only: file staged into the code-execution container input dir

ResponsesOutputMessageContentTypeText ResponsesMessageContentBlockType = "output_text"
ResponsesOutputMessageContentTypeRefusal ResponsesMessageContentBlockType = "refusal"
ResponsesOutputMessageContentTypeReasoning ResponsesMessageContentBlockType = "reasoning_text"
Expand Down
62 changes: 62 additions & 0 deletions transports/bifrost-http/integrations/anthropic_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package integrations

import (
"context"
"testing"

"github.com/maximhq/bifrost/core/providers/anthropic"
"github.com/maximhq/bifrost/core/schemas"
)

Expand Down Expand Up @@ -77,3 +79,63 @@ func TestMustConvertInPassthrough(t *testing.T) {
})
}
}

// A user message containing a container_upload block must survive the full
// Anthropic integration request pipeline (parse → Bifrost → normalize → Anthropic
// wire) with its file_id intact, and must NOT be replaced by the "..." empty-content
// placeholder that normalizeBifrostInputContentBlocks backfills for otherwise-empty
// user messages.
func TestAnthropicContainerUploadSurvivesNormalization(t *testing.T) {
ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background())
defer cancel()

fileID := "file_011CcpBQA2BV1gthmNPSYzkh"
req := &anthropic.AnthropicMessageRequest{
Model: "anthropic/claude-sonnet-4-6",
MaxTokens: 1024,
Messages: []anthropic.AnthropicMessage{
{
Role: anthropic.AnthropicMessageRoleUser,
Content: anthropic.AnthropicContent{
ContentBlocks: []anthropic.AnthropicContentBlock{
{Type: anthropic.AnthropicContentBlockTypeText, Text: schemas.Ptr("analyse and share model names")},
{Type: anthropic.AnthropicContentBlockTypeContainerUpload, FileID: &fileID},
},
},
},
},
}

bifrostReq := req.ToBifrostResponsesRequest(ctx)
normalizeBifrostInputContentBlocks(bifrostReq)

out, err := anthropic.ToAnthropicResponsesRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("ToAnthropicResponsesRequest: %v", err)
}

var containerFileID *string
sawPlaceholder := false
for _, m := range out.Messages {
for _, b := range m.Content.ContentBlocks {
switch b.Type {
case anthropic.AnthropicContentBlockTypeContainerUpload:
containerFileID = b.FileID
case anthropic.AnthropicContentBlockTypeText:
if b.Text != nil && *b.Text == "..." {
sawPlaceholder = true
}
}
}
}

if sawPlaceholder {
t.Errorf("container_upload was replaced by the \"...\" empty-content placeholder")
}
if containerFileID == nil {
t.Fatalf("container_upload block missing from Anthropic request")
}
if *containerFileID != fileID {
t.Errorf("file_id = %q, want %q", *containerFileID, fileID)
}
}
4 changes: 4 additions & 0 deletions transports/bifrost-http/integrations/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,10 @@ func isEffectivelyEmptyContent(content *schemas.ResponsesMessageContent) bool {
if block.ResponsesInputMessageContentBlockFile != nil {
return false
}
case schemas.ResponsesInputMessageContentBlockTypeContainer:
if block.FileID != nil {
return false
}
case schemas.ResponsesInputMessageContentBlockTypeAudio:
if block.Audio != nil {
return false
Expand Down
Loading