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
4 changes: 3 additions & 1 deletion core/internal/llmtests/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ type TestScenarios struct {
Realtime bool // Realtime API (bidirectional audio/text)
Compaction bool // Server-side compaction (context management)
InterleavedThinking bool // Interleaved thinking between tool calls (beta)
FastMode bool // Fast mode for Opus 4.6 (beta: research preview)
FastMode bool // Fast mode for Opus 4.6 (beta: research preview)
EagerInputStreaming bool // Fine-grained tool input streaming (Anthropic fine-grained-tool-streaming-2025-05-14)
ServerToolsViaOpenAIEndpoint bool // Anthropic server-tool shapes in tools[] via /v1/chat/completions (web_search / web_fetch / code_execution)
}

// ComprehensiveTestConfig extends TestConfig with additional scenarios
Expand Down
134 changes: 134 additions & 0 deletions core/internal/llmtests/eager_input_streaming.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package llmtests

import (
"context"
"os"
"testing"

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

// RunEagerInputStreamingTest tests that setting eager_input_streaming: true on
// a custom tool succeeds end-to-end against the target Anthropic-family
// provider. Per Table 20 (verified against A overview + B-header), the
// fine-grained-tool-streaming-2025-05-14 beta is supported on Anthropic,
// Bedrock, Vertex, and Azure.
//
// The test verifies:
// 1. The request is accepted (no upstream 400 — which would indicate the
// fine-grained-tool-streaming-2025-05-14 beta header wasn't injected or
// is rejected by the target provider).
// 2. The stream produces a tool call with a valid JSON arguments payload.
// 3. The response is otherwise well-formed.
//
// This intentionally runs across all four providers (no single-provider gate
// unlike RunFastModeTest, which is Opus-4.6-only).
func RunEagerInputStreamingTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig ComprehensiveTestConfig) {
if !testConfig.Scenarios.EagerInputStreaming {
t.Logf("EagerInputStreaming not supported for provider %s", testConfig.Provider)
return
}

t.Run("EagerInputStreaming", func(t *testing.T) {
if os.Getenv("SKIP_PARALLEL_TESTS") != "true" {
t.Parallel()
}

chatTool := GetSampleChatTool(SampleToolTypeWeather)
// Opt the tool into fine-grained input streaming. The neutral flag
// on ChatTool is promoted through ToAnthropicChatRequest, which also
// triggers the fine-grained-tool-streaming-2025-05-14 beta header.
eager := true
chatTool.EagerInputStreaming = &eager

chatMessages := []schemas.ChatMessage{
CreateBasicChatMessage("What's the weather like in San Francisco? answer in celsius"),
}

request := &schemas.BifrostChatRequest{
Provider: testConfig.Provider,
Model: testConfig.ChatModel,
Input: chatMessages,
Params: &schemas.ChatParameters{
MaxCompletionTokens: bifrost.Ptr(200),
Tools: []schemas.ChatTool{*chatTool},
},
Fallbacks: testConfig.Fallbacks,
}

retryConfig := StreamingRetryConfig()
retryContext := TestRetryContext{
ScenarioName: "EagerInputStreaming",
ExpectedBehavior: map[string]interface{}{
"should_stream_content": true,
"should_have_tool_calls": true,
"tool_name": "get_weather",
},
TestMetadata: map[string]interface{}{
"provider": testConfig.Provider,
"model": testConfig.ChatModel,
"eager_input_streaming": true,
},
}

responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
bfCtx := schemas.NewBifrostContext(ctx, schemas.NoDeadline)
return client.ChatCompletionStreamRequest(bfCtx, request)
})

RequireNoError(t, err, "Eager input streaming request failed")
if responseChannel == nil {
t.Fatal("Response channel should not be nil")
}

accumulator := NewStreamingToolCallAccumulator()
var responseCount int
var sawAny bool

t.Logf("🔧 Testing eager input streaming (fine-grained-tool-streaming-2025-05-14)...")

for response := range responseChannel {
if response == nil || response.BifrostChatResponse == nil {
continue
}
responseCount++
sawAny = true

if response.BifrostChatResponse.Choices != nil {
for i, choice := range response.BifrostChatResponse.Choices {
if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil {
delta := choice.ChatStreamResponseChoice.Delta
for _, tc := range delta.ToolCalls {
accumulator.AccumulateChatToolCall(i, tc)
}
}
}
}
}

if !sawAny {
t.Fatal("Expected at least one streaming response chunk")
}
t.Logf("Received %d chunks", responseCount)

// Validate the accumulated tool call is well-formed. If the
// fine-grained-tool-streaming beta header weren't sent (or the
// provider rejected it), the upstream would have returned a 400
// before any tool_use blocks were emitted.
toolCalls := accumulator.GetFinalChatToolCalls()
if len(toolCalls) == 0 {
t.Error("Expected at least one tool call in stream")
}
for _, tc := range toolCalls {
if tc.Name == "" {
t.Error("Tool call missing function name")
}
if tc.Arguments == "" {
t.Error("Tool call missing arguments JSON")
}
}

t.Logf("EagerInputStreaming passed: %d tool calls accumulated", len(toolCalls))
})
}
71 changes: 71 additions & 0 deletions core/internal/llmtests/provider_feature_support_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,77 @@ func TestProviderBetaHeaderInjection(t *testing.T) {
},
expectHeaders: []string{"computer-use-2025-01-24"},
},

// ── Fine-grained tool streaming header (eager_input_streaming) ──
// Per cited citations (A overview table + B-header): EagerInputStreaming
// is supported on Anthropic, Bedrock, Vertex, and Azure — all four
// should auto-inject fine-grained-tool-streaming-2025-05-14 when a
// tool has eager_input_streaming: true.
{
name: "Anthropic/eager_input_streaming_header_added",
provider: schemas.Anthropic,
setupReq: func() *anthropic.AnthropicMessageRequest {
eager := true
return &anthropic.AnthropicMessageRequest{
Tools: []anthropic.AnthropicTool{{Name: "t1", EagerInputStreaming: &eager}},
}
},
expectHeaders: []string{"fine-grained-tool-streaming-2025-05-14"},
},
{
name: "Bedrock/eager_input_streaming_header_added",
provider: schemas.Bedrock,
setupReq: func() *anthropic.AnthropicMessageRequest {
eager := true
return &anthropic.AnthropicMessageRequest{
Tools: []anthropic.AnthropicTool{{Name: "t1", EagerInputStreaming: &eager}},
}
},
expectHeaders: []string{"fine-grained-tool-streaming-2025-05-14"},
},
{
name: "Vertex/eager_input_streaming_header_added",
provider: schemas.Vertex,
setupReq: func() *anthropic.AnthropicMessageRequest {
eager := true
return &anthropic.AnthropicMessageRequest{
Tools: []anthropic.AnthropicTool{{Name: "t1", EagerInputStreaming: &eager}},
}
},
expectHeaders: []string{"fine-grained-tool-streaming-2025-05-14"},
},
{
name: "Azure/eager_input_streaming_header_added",
provider: schemas.Azure,
setupReq: func() *anthropic.AnthropicMessageRequest {
eager := true
return &anthropic.AnthropicMessageRequest{
Tools: []anthropic.AnthropicTool{{Name: "t1", EagerInputStreaming: &eager}},
}
},
expectHeaders: []string{"fine-grained-tool-streaming-2025-05-14"},
},
{
name: "eager_input_streaming_header_skipped_when_flag_false",
provider: schemas.Anthropic,
setupReq: func() *anthropic.AnthropicMessageRequest {
eager := false
return &anthropic.AnthropicMessageRequest{
Tools: []anthropic.AnthropicTool{{Name: "t1", EagerInputStreaming: &eager}},
}
},
unexpectHeaders: []string{"fine-grained-tool-streaming-2025-05-14"},
},
{
name: "eager_input_streaming_header_skipped_when_unset",
provider: schemas.Anthropic,
setupReq: func() *anthropic.AnthropicMessageRequest {
return &anthropic.AnthropicMessageRequest{
Tools: []anthropic.AnthropicTool{{Name: "t1"}},
}
},
unexpectHeaders: []string{"fine-grained-tool-streaming-2025-05-14"},
},
}

for _, tt := range tests {
Expand Down
2 changes: 2 additions & 0 deletions core/internal/llmtests/tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func RunAllComprehensiveTests(t *testing.T, client *bifrost.Bifrost, ctx context
RunCompactionTest,
RunInterleavedThinkingTest,
RunFastModeTest,
RunEagerInputStreamingTest,
Comment thread
akshaydeo marked this conversation as resolved.
}

// Execute all test scenarios without raw request/response (default behavior)
Expand Down Expand Up @@ -239,6 +240,7 @@ func printTestSummary(t *testing.T, testConfig ComprehensiveTestConfig) {
{"Compaction", testConfig.Scenarios.Compaction},
{"InterleavedThinking", testConfig.Scenarios.InterleavedThinking},
{"FastMode", testConfig.Scenarios.FastMode},
{"EagerInputStreaming", testConfig.Scenarios.EagerInputStreaming},
}

supported := 0
Expand Down
4 changes: 3 additions & 1 deletion core/providers/anthropic/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ func TestAnthropic(t *testing.T) {
PassthroughAPI: true,
Compaction: true,
InterleavedThinking: true,
FastMode: false, // Enable when test API key has Opus 4.6 access
FastMode: false, // Enable when test API key has Opus 4.6 access
EagerInputStreaming: true, // fine-grained-tool-streaming-2025-05-14 (GA on Anthropic)
ServerToolsViaOpenAIEndpoint: true, // web_search / web_fetch / code_execution via /v1/chat/completions
},
}

Expand Down
7 changes: 7 additions & 0 deletions core/providers/anthropic/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif
anthropicTool.CacheControl = tool.CacheControl
}

// Fine-grained tool streaming — promoted neutral flag on ChatTool.
// Anthropic auto-injects beta header fine-grained-tool-streaming-2025-05-14
// via AddMissingBetaHeadersToContext when this is set.
if tool.EagerInputStreaming != nil {
anthropicTool.EagerInputStreaming = tool.EagerInputStreaming
}

tools = append(tools, anthropicTool)
}
if anthropicReq.Tools == nil {
Expand Down
2 changes: 1 addition & 1 deletion core/providers/anthropic/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestToAnthropicChatRequest_CachingDeterminism(t *testing.T) {
Model: "claude-sonnet-4-20250514",
Input: []schemas.ChatMessage{{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
Content: &schemas.ChatMessageContent{ContentStr: new("test")},
}},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{{
Expand Down
38 changes: 21 additions & 17 deletions core/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -3437,7 +3437,7 @@ func convertAnthropicContentBlocksToResponsesMessagesGrouped(contentBlocks []Ant

case AnthropicContentBlockTypeImage:
// Don't emit accumulated text or tool_use blocks for images
if block.Source != nil {
if block.Source != nil && block.Source.SourceObj != nil {
bifrostMsg := schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: role,
Expand All @@ -3453,7 +3453,7 @@ func convertAnthropicContentBlocksToResponsesMessagesGrouped(contentBlocks []Ant

case AnthropicContentBlockTypeDocument:
// Handle document blocks similar to images
if block.Source != nil {
if block.Source != nil && block.Source.SourceObj != nil {
bifrostMsg := schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: role,
Expand Down Expand Up @@ -3543,7 +3543,7 @@ func convertAnthropicContentBlocksToResponsesMessagesGrouped(contentBlocks []Ant
})
}
case AnthropicContentBlockTypeImage:
if contentBlock.Source != nil {
if contentBlock.Source != nil && contentBlock.Source.SourceObj != nil {
toolMsgContentBlocks = append(toolMsgContentBlocks, contentBlock.toBifrostResponsesImageBlock())
}
}
Expand Down Expand Up @@ -3756,7 +3756,7 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex
bifrostMessages = append(bifrostMessages, bifrostMsg)
}
case AnthropicContentBlockTypeImage:
if block.Source != nil {
if block.Source != nil && block.Source.SourceObj != nil {
bifrostMsg := schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: role,
Expand All @@ -3770,7 +3770,7 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex
bifrostMessages = append(bifrostMessages, bifrostMsg)
}
case AnthropicContentBlockTypeDocument:
if block.Source != nil {
if block.Source != nil && block.Source.SourceObj != nil {
bifrostMsg := schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: role,
Expand Down Expand Up @@ -3904,7 +3904,7 @@ func convertAnthropicContentBlocksToResponsesMessages(ctx *schemas.BifrostContex
})
}
case AnthropicContentBlockTypeImage:
if contentBlock.Source != nil {
if contentBlock.Source != nil && contentBlock.Source.SourceObj != nil {
toolMsgContentBlocks = append(toolMsgContentBlocks, contentBlock.toBifrostResponsesImageBlock())
}
}
Expand Down Expand Up @@ -5200,36 +5200,40 @@ func (block AnthropicContentBlock) toBifrostResponsesDocumentBlock() schemas.Res
resultBlock.ResponsesInputMessageContentBlockFile.Filename = block.Title
}

if block.Source == nil {
if block.Source == nil || block.Source.SourceObj == nil {
// File-block rendering only applies to object-form sources
// (image / document). String-form sources (search_result) are
// handled elsewhere.
return resultBlock
}
src := block.Source.SourceObj

// Handle different source types
switch block.Source.Type {
switch src.Type {
case "url":
// URL source
if block.Source.URL != nil {
resultBlock.ResponsesInputMessageContentBlockFile.FileURL = block.Source.URL
if src.URL != nil {
resultBlock.ResponsesInputMessageContentBlockFile.FileURL = src.URL
}
case "base64":
// Base64 encoded data
if block.Source.Data != nil {
if src.Data != nil {
// Construct data URL with media type
mediaType := "application/pdf"
if block.Source.MediaType != nil {
mediaType = *block.Source.MediaType
if src.MediaType != nil {
mediaType = *src.MediaType
}
dataURL := *block.Source.Data
dataURL := *src.Data
if !strings.HasPrefix(dataURL, "data:") {
dataURL = "data:" + mediaType + ";base64," + *block.Source.Data
dataURL = "data:" + mediaType + ";base64," + *src.Data
}
resultBlock.ResponsesInputMessageContentBlockFile.FileData = &dataURL
}
case "text":
// Plain text source
if block.Source.Data != nil {
if src.Data != nil {
resultBlock.ResponsesInputMessageContentBlockFile.FileType = schemas.Ptr("text/plain")
resultBlock.ResponsesInputMessageContentBlockFile.FileData = block.Source.Data
resultBlock.ResponsesInputMessageContentBlockFile.FileData = src.Data
}
}

Expand Down
Loading
Loading