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
86 changes: 77 additions & 9 deletions core/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type AnthropicResponsesStreamState struct {
ReasoningSignatures map[int]string // Maps output_index to reasoning signature
TextContentIndices map[int]bool // Tracks which content indices are text blocks
ReasoningContentIndices map[int]bool // Tracks which content indices are reasoning blocks
TextBuffers map[int]*strings.Builder // Maps output_index to accumulated text content for done events
CompactionContentIndices map[int]*schemas.CacheControl // Tracks pending compaction blocks with their cache control
CurrentOutputIndex int // Current output index counter
MessageID *string // Message ID from message_start
Expand Down Expand Up @@ -71,6 +72,7 @@ var anthropicResponsesStreamStatePool = sync.Pool{
ReasoningContentIndices: make(map[int]bool),
CompactionContentIndices: make(map[int]*schemas.CacheControl),
OutputItems: make(map[int]*schemas.ResponsesMessage),
TextBuffers: make(map[int]*strings.Builder),
CurrentOutputIndex: 0,
CreatedAt: int(time.Now().Unix()),
HasEmittedCreated: false,
Expand Down Expand Up @@ -147,6 +149,11 @@ func acquireAnthropicResponsesStreamState() *AnthropicResponsesStreamState {
} else {
clear(state.ReasoningContentIndices)
}
if state.TextBuffers == nil {
state.TextBuffers = make(map[int]*strings.Builder)
} else {
clear(state.TextBuffers)
}
if state.CompactionContentIndices == nil {
state.CompactionContentIndices = make(map[int]*schemas.CacheControl)
} else {
Expand Down Expand Up @@ -207,6 +214,7 @@ func (state *AnthropicResponsesStreamState) flush() {
state.ReasoningSignatures = nil
state.TextContentIndices = nil
state.ReasoningContentIndices = nil
state.TextBuffers = nil
state.CompactionContentIndices = nil
state.OutputItems = nil
state.CurrentOutputIndex = 0
Expand Down Expand Up @@ -837,6 +845,12 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context,
}
case AnthropicStreamDeltaTypeText:
if chunk.Delta.Text != nil && *chunk.Delta.Text != "" {
// Accumulate text for done events
if state.TextBuffers[outputIndex] == nil {
state.TextBuffers[outputIndex] = &strings.Builder{}
}
state.TextBuffers[outputIndex].WriteString(*chunk.Delta.Text)

// Text content delta - emit output_text.delta with item ID
itemID := state.ItemIDs[outputIndex]
response := &schemas.BifrostResponsesStreamResponse{
Expand Down Expand Up @@ -1124,29 +1138,44 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context,
var responses []*schemas.BifrostResponsesStreamResponse
itemID := state.ItemIDs[outputIndex]

// Capture accumulated text once — shared by output_text.done and output_item.done
accText := ""
if buf := state.TextBuffers[outputIndex]; buf != nil {
accText = buf.String()
}

// Check if this content index is a text block
if chunk.Index != nil {
if state.TextContentIndices[*chunk.Index] {
// Emit output_text.done (without accumulated text, just the event)
emptyText := ""
// Emit output_text.done with full accumulated text
textDoneResponse := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputTextDone,
SequenceNumber: sequenceNumber + len(responses),
OutputIndex: schemas.Ptr(outputIndex),
ContentIndex: chunk.Index,
Text: &emptyText,
Text: &accText,
}
if itemID != "" {
textDoneResponse.ItemID = &itemID
}
responses = append(responses, textDoneResponse)

// Emit content_part.done
// Emit content_part.done with full accumulated text in Part
partText := accText
part := &schemas.ResponsesMessageContentBlock{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: &partText,
ResponsesOutputMessageContentText: &schemas.ResponsesOutputMessageContentText{
Annotations: []schemas.ResponsesOutputMessageContentTextAnnotation{},
LogProbs: []schemas.ResponsesOutputMessageContentTextLogProb{},
},
}
partDoneResponse := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeContentPartDone,
SequenceNumber: sequenceNumber + len(responses),
OutputIndex: schemas.Ptr(outputIndex),
ContentIndex: chunk.Index,
Part: part,
}
if itemID != "" {
partDoneResponse.ItemID = &itemID
Expand Down Expand Up @@ -1292,17 +1321,42 @@ func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(ctx context.Context,
}
doneItem = &copied
} else {
// Build content blocks from accumulated text (captured above)
contentBlocks := []schemas.ResponsesMessageContentBlock{}
if accText != "" {
textCopy := accText
contentBlocks = []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: &textCopy,
ResponsesOutputMessageContentText: &schemas.ResponsesOutputMessageContentText{
Annotations: []schemas.ResponsesOutputMessageContentTextAnnotation{},
LogProbs: []schemas.ResponsesOutputMessageContentTextLogProb{},
},
},
}
}
delete(state.TextBuffers, outputIndex)
doneItem = &schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Status: &statusCompleted,
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{},
ContentBlocks: contentBlocks,
},
}
if doneItemID != "" {
doneItem.ID = &doneItemID
}
// Only persist synthesized items that actually have text content — reasoning
// and MCP blocks fall through here without storedItems but must not pollute
// response.completed with empty assistant message shells.
if len(contentBlocks) > 0 {
cloned := *doneItem
clonedContent := *doneItem.Content
cloned.Content = &clonedContent
state.OutputItems[outputIndex] = &cloned
}
}
responses = append(responses, &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemDone,
Expand Down Expand Up @@ -4352,6 +4406,10 @@ func convertBifrostFunctionCallToAnthropicToolUse(ctx *schemas.BifrostContext, m
}
}
toolUseBlock.Input = parseJSONInput(argumentsJSON)
} else {
// Anthropic requires input to always be present on tool_use blocks;
// default to an empty object for tools that take no arguments.
toolUseBlock.Input = json.RawMessage("{}")
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return &toolUseBlock
Expand Down Expand Up @@ -4520,6 +4578,10 @@ func convertBifrostMCPCallToAnthropicToolUse(msg *schemas.ResponsesMessage) *Ant
// Parse arguments as JSON input
if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" {
toolUseBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments)
} else {
// Anthropic requires input to always be present on tool_use blocks;
// default to an empty object for tools that take no arguments.
toolUseBlock.Input = json.RawMessage("{}")
}

return &toolUseBlock
Expand Down Expand Up @@ -4566,6 +4628,10 @@ func convertBifrostMCPApprovalToAnthropicToolUse(msg *schemas.ResponsesMessage)
// Parse arguments as JSON input
if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" {
toolUseBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments)
} else {
// Anthropic requires input to always be present on tool_use blocks;
// default to an empty object for tools that take no arguments.
toolUseBlock.Input = json.RawMessage("{}")
}

return &toolUseBlock
Expand Down Expand Up @@ -5192,12 +5258,14 @@ func convertBifrostToolToAnthropic(model string, tool *schemas.ResponsesTool, pr
}
}

anthropicTool := &AnthropicTool{}

if tool.Name != nil {
anthropicTool.Name = *tool.Name
// Skip tools with no name — Anthropic rejects them
if tool.Name == nil || *tool.Name == "" {
return nil
}

anthropicTool := &AnthropicTool{}
anthropicTool.Name = *tool.Name

if tool.Description != nil {
anthropicTool.Description = tool.Description
}
Expand Down
155 changes: 155 additions & 0 deletions core/providers/anthropic/toolinput_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package anthropic

import (
"context"
"testing"

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

// TestConvertBifrostFunctionCallToAnthropicToolUse_Input verifies that the
// tool_use block always carries an "input" object, defaulting to "{}" when the
// function call has nil or empty arguments (tools that take no arguments).
func TestConvertBifrostFunctionCallToAnthropicToolUse_Input(t *testing.T) {
t.Parallel()

ctx, cancel := schemas.NewBifrostContextWithCancel(context.Background())
defer cancel()

tests := []struct {
name string
arguments *string
wantInput string
}{
{name: "nil arguments", arguments: nil, wantInput: "{}"},
{name: "empty arguments", arguments: schemas.Ptr(""), wantInput: "{}"},
{name: "populated arguments", arguments: schemas.Ptr(`{"foo":"bar"}`), wantInput: `{"foo":"bar"}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

msg := &schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("toolu_fn_test"),
Name: schemas.Ptr("get_workspace_id"),
Arguments: tt.arguments,
},
}

block := convertBifrostFunctionCallToAnthropicToolUse(ctx, msg)
if block == nil {
t.Fatal("expected non-nil tool_use block")
}
if block.Type != AnthropicContentBlockTypeToolUse {
t.Errorf("block.Type = %v, want %v", block.Type, AnthropicContentBlockTypeToolUse)
}
if block.Input == nil {
t.Fatal("expected non-nil Input")
}
if string(block.Input) != tt.wantInput {
t.Errorf("Input = %s, want %s", block.Input, tt.wantInput)
}
})
}
}

// TestConvertBifrostMCPCallToAnthropicToolUse_Input verifies that the
// mcp_tool_use block always carries an "input" object, defaulting to "{}" when
// the MCP call has nil or empty arguments.
func TestConvertBifrostMCPCallToAnthropicToolUse_Input(t *testing.T) {
t.Parallel()

tests := []struct {
name string
arguments *string
wantInput string
}{
{name: "nil arguments", arguments: nil, wantInput: "{}"},
{name: "empty arguments", arguments: schemas.Ptr(""), wantInput: "{}"},
{name: "populated arguments", arguments: schemas.Ptr(`{"foo":"bar"}`), wantInput: `{"foo":"bar"}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

msg := &schemas.ResponsesMessage{
ID: schemas.Ptr("mcp_call_test"),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMCPCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
Name: schemas.Ptr("maximsse-get-maxim-workspace-id"),
Arguments: tt.arguments,
ResponsesMCPToolCall: &schemas.ResponsesMCPToolCall{
ServerLabel: "maximsse",
},
},
}

block := convertBifrostMCPCallToAnthropicToolUse(msg)
if block == nil {
t.Fatal("expected non-nil mcp_tool_use block")
}
if block.Type != AnthropicContentBlockTypeMCPToolUse {
t.Errorf("block.Type = %v, want %v", block.Type, AnthropicContentBlockTypeMCPToolUse)
}
if block.Input == nil {
t.Fatal("expected non-nil Input")
}
if string(block.Input) != tt.wantInput {
t.Errorf("Input = %s, want %s", block.Input, tt.wantInput)
}
})
}
}

// TestConvertBifrostMCPApprovalToAnthropicToolUse_Input verifies that the
// mcp_tool_use block produced for an MCP approval request always carries an
// "input" object, defaulting to "{}" when arguments are nil or empty.
func TestConvertBifrostMCPApprovalToAnthropicToolUse_Input(t *testing.T) {
t.Parallel()

tests := []struct {
name string
arguments *string
wantInput string
}{
{name: "nil arguments", arguments: nil, wantInput: "{}"},
{name: "empty arguments", arguments: schemas.Ptr(""), wantInput: "{}"},
{name: "populated arguments", arguments: schemas.Ptr(`{"foo":"bar"}`), wantInput: `{"foo":"bar"}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

msg := &schemas.ResponsesMessage{
ID: schemas.Ptr("mcp_approval_test"),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMCPApprovalRequest),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
Name: schemas.Ptr("maximsse-get-maxim-workspace-id"),
Arguments: tt.arguments,
ResponsesMCPToolCall: &schemas.ResponsesMCPToolCall{
ServerLabel: "maximsse",
},
},
}

block := convertBifrostMCPApprovalToAnthropicToolUse(msg)
if block == nil {
t.Fatal("expected non-nil mcp_tool_use block")
}
if block.Type != AnthropicContentBlockTypeMCPToolUse {
t.Errorf("block.Type = %v, want %v", block.Type, AnthropicContentBlockTypeMCPToolUse)
}
if block.Input == nil {
t.Fatal("expected non-nil Input")
}
if string(block.Input) != tt.wantInput {
t.Errorf("Input = %s, want %s", block.Input, tt.wantInput)
}
})
}
}
Loading
Loading