Skip to content
Open
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
74 changes: 73 additions & 1 deletion core/providers/anthropic/compaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -521,10 +521,38 @@ func TestToAnthropicResponsesResponse_StopReasonFromBifrost(t *testing.T) {
expectedReason: AnthropicStopReasonEndTurn,
},
{
name: "tool_use mapped from tool_calls",
name: "tool_use mapped from tool_calls without output items",
stopReason: schemas.Ptr("tool_calls"),
expectedReason: AnthropicStopReasonToolUse,
},
{
name: "reasoning-only function_call output does not infer tool_use",
stopReason: nil,
contentBlocks: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesReasoning: &schemas.ResponsesReasoning{
EncryptedContent: schemas.Ptr("opaque-reasoning-payload"),
},
},
},
expectedReason: AnthropicStopReasonEndTurn,
},
{
name: "tool_use mapped from tool_calls with function call output",
stopReason: schemas.Ptr("tool_calls"),
contentBlocks: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_123"),
Name: schemas.Ptr("my_tool"),
Arguments: schemas.Ptr(`{"foo":"bar"}`),
},
},
},
expectedReason: AnthropicStopReasonToolUse,
},
{
name: "nil stop_reason defaults to end_turn",
stopReason: nil,
Expand Down Expand Up @@ -702,6 +730,50 @@ func TestToAnthropicResponsesStreamResponse_CompletedWithCompactionStopReason(t
}
}

func TestToAnthropicResponsesStreamResponse_CompletedWithFunctionCallInfersToolUseStopReason(t *testing.T) {
t.Parallel()

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

bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeCompleted,
Response: &schemas.BifrostResponsesResponse{
ID: schemas.Ptr("resp_test"),
Model: "gpt-4o",
Output: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_123"),
Name: schemas.Ptr("my_tool"),
Arguments: schemas.Ptr(`{"foo":"bar"}`),
},
},
},
},
}

events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
if len(events) != 2 {
t.Fatalf("expected message_delta + message_stop for response.completed, got %d", len(events))
}

messageDelta := events[0]
if messageDelta.Type != AnthropicStreamEventTypeMessageDelta {
t.Fatalf("event[0] type = %v, want message_delta", messageDelta.Type)
}
if messageDelta.Delta == nil || messageDelta.Delta.StopReason == nil {
t.Fatalf("expected message_delta stop_reason in events: %#v", events)
}
if *messageDelta.Delta.StopReason != AnthropicStopReasonToolUse {
t.Fatalf("message_delta stop_reason = %q, want %q", *messageDelta.Delta.StopReason, AnthropicStopReasonToolUse)
}
if events[1].Type != AnthropicStreamEventTypeMessageStop {
t.Fatalf("event[1] type = %v, want message_stop", events[1].Type)
}
}

func TestMixedTextThenToolCallsStreamMapsStopReasonToToolUse(t *testing.T) {
t.Parallel()

Expand Down
62 changes: 42 additions & 20 deletions core/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -2682,22 +2682,19 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp
if alreadyEmitted, ok := ctx.Value(schemas.BifrostContextKeyHasEmittedMessageDelta).(bool); ok && alreadyEmitted {
return []*AnthropicStreamEvent{streamResp}
}
stopReason := AnthropicStopReasonEndTurn
if bifrostResp.Response != nil {
stopReason = inferAnthropicStopReasonFromBifrostResponse(bifrostResp.Response, nil)
}
anthropicContentDeltaEvent := &AnthropicStreamEvent{
Type: AnthropicStreamEventTypeMessageDelta,
Delta: &AnthropicStreamDelta{
StopReason: schemas.Ptr(AnthropicStopReasonEndTurn),
StopSequence: schemas.Ptr(""),
StopReason: &stopReason,
},
}
// Convert usage from Bifrost to Anthropic
if bifrostResp.Response != nil {
anthropicContentDeltaEvent.Usage = ConvertBifrostUsageToAnthropicUsage(bifrostResp.Response.Usage)
if bifrostResp.Response.StopReason != nil {
anthropicContentDeltaEvent.Delta = &AnthropicStreamDelta{
StopReason: schemas.Ptr(ConvertBifrostFinishReasonToAnthropic(*bifrostResp.Response.StopReason)),
StopSequence: nil,
}
}
// Re-emit the code-execution sandbox container on the message_delta.
if bifrostResp.Response.Container != nil {
if anthropicContentDeltaEvent.Delta == nil {
Expand Down Expand Up @@ -3541,6 +3538,42 @@ func (response *AnthropicMessageResponse) ToBifrostResponsesResponse(ctx *schema
return bifrostResp
}

func bifrostResponsesOutputHasFunctionCall(output []schemas.ResponsesMessage) bool {
for _, msg := range output {
// Reasoning-only items can carry the function_call type but convert to
// thinking blocks, not tool_use — they must not force a tool_use stop reason.
if msg.Type != nil && *msg.Type == schemas.ResponsesMessageTypeFunctionCall &&
msg.ResponsesReasoning == nil && msg.ResponsesToolMessage != nil {
return true
}
}
return false
}

func anthropicContentBlocksHaveToolUse(contentBlocks []AnthropicContentBlock) bool {
for _, block := range contentBlocks {
if block.Type == AnthropicContentBlockTypeToolUse {
return true
}
}
return false
}

func inferAnthropicStopReasonFromBifrostResponse(bifrostResp *schemas.BifrostResponsesResponse, contentBlocks []AnthropicContentBlock) AnthropicStopReason {
if bifrostResp != nil {
if bifrostResp.StopReason != nil {
return ConvertBifrostFinishReasonToAnthropic(*bifrostResp.StopReason)
}
if bifrostResponsesOutputHasFunctionCall(bifrostResp.Output) {
return AnthropicStopReasonToolUse
}
}
if anthropicContentBlocksHaveToolUse(contentBlocks) {
return AnthropicStopReasonToolUse
}
return AnthropicStopReasonEndTurn
}

// ToAnthropicResponsesResponse converts a BifrostResponse with Responses structure back to AnthropicMessageResponse
func ToAnthropicResponsesResponse(ctx *schemas.BifrostContext, bifrostResp *schemas.BifrostResponsesResponse) *AnthropicMessageResponse {
anthropicResp := &AnthropicMessageResponse{
Expand Down Expand Up @@ -3596,18 +3629,7 @@ func ToAnthropicResponsesResponse(ctx *schemas.BifrostContext, bifrostResp *sche
break
}

// Map stop reason from Bifrost response if available, otherwise infer from content
if bifrostResp.StopReason != nil {
anthropicResp.StopReason = ConvertBifrostFinishReasonToAnthropic(*bifrostResp.StopReason)
} else {
anthropicResp.StopReason = AnthropicStopReasonEndTurn
for _, block := range contentBlocks {
if block.Type == AnthropicContentBlockTypeToolUse {
anthropicResp.StopReason = AnthropicStopReasonToolUse
break
}
}
}
anthropicResp.StopReason = inferAnthropicStopReasonFromBifrostResponse(bifrostResp, contentBlocks)

anthropicResp.Model = bifrostResp.Model

Expand Down