From d9337659ce536024c5faeb4b68e621dd472c4773 Mon Sep 17 00:00:00 2001 From: tejas ghatte Date: Wed, 8 Jul 2026 13:42:27 +0530 Subject: [PATCH] fix: pass container block from anthropic api --- core/providers/anthropic/responses.go | 45 +++++++++++ core/providers/anthropic/roundtrip_test.go | 79 +++++++++++++++++++ core/schemas/responses.go | 10 ++- .../integrations/anthropic_test.go | 62 +++++++++++++++ .../bifrost-http/integrations/cursor.go | 4 + 5 files changed, 196 insertions(+), 4 deletions(-) diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index 89ed9bf7413..2016073d37a 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -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{ @@ -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 @@ -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{ @@ -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, diff --git a/core/providers/anthropic/roundtrip_test.go b/core/providers/anthropic/roundtrip_test.go index ffb1aebcaff..f9047d8bd43 100644 --- a/core/providers/anthropic/roundtrip_test.go +++ b/core/providers/anthropic/roundtrip_test.go @@ -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) + } +} diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 7119f7b748a..ad54af68660 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -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" diff --git a/transports/bifrost-http/integrations/anthropic_test.go b/transports/bifrost-http/integrations/anthropic_test.go index ea76123b351..eb717286fb9 100644 --- a/transports/bifrost-http/integrations/anthropic_test.go +++ b/transports/bifrost-http/integrations/anthropic_test.go @@ -1,8 +1,10 @@ package integrations import ( + "context" "testing" + "github.com/maximhq/bifrost/core/providers/anthropic" "github.com/maximhq/bifrost/core/schemas" ) @@ -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) + } +} diff --git a/transports/bifrost-http/integrations/cursor.go b/transports/bifrost-http/integrations/cursor.go index 586f038f7cd..8ccdc15679f 100644 --- a/transports/bifrost-http/integrations/cursor.go +++ b/transports/bifrost-http/integrations/cursor.go @@ -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