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
76 changes: 69 additions & 7 deletions core/providers/bedrock/bedrock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2544,6 +2544,41 @@ func TestGuardrailConfigRequestRoundTrip(t *testing.T) {
assert.Nil(t, result.ExtraParams, "ExtraParams should be nil after all keys are extracted")
}

// TestContentFilterMapsToIncomplete verifies that a Bedrock content-filter /
// guardrail stop reason (which returns no output message and zero usage) is
// surfaced as Responses status "incomplete" with incomplete_details.reason
// "content_filter", instead of being normalized to a successful empty "completed"
// result that downstream agents cannot distinguish from a genuine empty turn.
func TestContentFilterMapsToIncomplete(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)

cases := []struct {
name string
bedrockStop string
expectStopReason string
}{
{"content_filtered", "content_filtered", "content_filter"},
{"guardrail_intervened", "guardrail_intervened", "guardrail_intervened"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
original := &bedrock.BedrockConverseResponse{
StopReason: tc.bedrockStop,
}

bifrostResp, err := original.ToBifrostResponsesResponse(ctx)
require.NoError(t, err)
require.NotNil(t, bifrostResp.Status, "status must be set, not left to default to completed")
assert.Equal(t, schemas.ResponsesResponseStatusIncomplete, *bifrostResp.Status)
require.NotNil(t, bifrostResp.IncompleteDetails)
assert.Equal(t, schemas.ResponsesResponseIncompleteReasonContentFilter, bifrostResp.IncompleteDetails.Reason)
require.NotNil(t, bifrostResp.StopReason)
assert.Equal(t, tc.expectStopReason, *bifrostResp.StopReason)
})
}
}

// TestGuardrailTraceResponseRoundTrip verifies the full trace response round-trip:
//
// BedrockConverseResponse.Trace
Expand Down Expand Up @@ -4826,9 +4861,9 @@ func TestBedrockStopReasonMappingResponsesPath(t *testing.T) {
{"MaxTokens", "max_tokens", "length", "incomplete", "max_output_tokens"},
{"StopSequence", "stop_sequence", "stop", "completed", ""},
{"ToolUse", "tool_use", "tool_calls", "completed", ""},
{"ContentFiltered", "content_filtered", "content_filter", "", ""}, // no clean mapping — passes through, no Status
{"GuardrailIntervened", "guardrail_intervened", "guardrail_intervened", "", ""}, // no clean mapping — passes through, no Status
{"UnknownReason", "some_unknown_reason", "some_unknown_reason", "", ""}, // no clean mapping — passes through, no Status
{"ContentFiltered", "content_filtered", "content_filter", "incomplete", "content_filter"}, // filtered → incomplete + content_filter
{"GuardrailIntervened", "guardrail_intervened", "guardrail_intervened", "incomplete", "content_filter"}, // guardrail block → incomplete + content_filter
{"UnknownReason", "some_unknown_reason", "some_unknown_reason", "", ""}, // no clean mapping — passes through, no Status
}

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
Expand Down Expand Up @@ -4917,12 +4952,12 @@ func TestFinalizeBedrockStream_CleanCompletionUnaffected(t *testing.T) {
}

// TestFinalizeBedrockStream_UnmappedReasonLeavesStatusUnset keeps the streaming
// path aligned with the non-streaming mapping: an unmapped stop reason (e.g.
// content_filter) ends the stream as response.completed but must leave Status
// unset rather than asserting "completed".
// path aligned with the non-streaming mapping: an unmapped stop reason ends the
// stream as response.completed but must leave Status unset rather than asserting
// "completed".
func TestFinalizeBedrockStream_UnmappedReasonLeavesStatusUnset(t *testing.T) {
state := bedrock.NewBedrockResponsesStreamState()
state.StopReason = schemas.Ptr("content_filter")
state.StopReason = schemas.Ptr("some_unknown_reason")
usage := &schemas.ResponsesResponseUsage{InputTokens: 5, OutputTokens: 10, TotalTokens: 15}

finalResponses := bedrock.FinalizeBedrockStream(state, 0, usage, nil)
Expand All @@ -4935,6 +4970,33 @@ func TestFinalizeBedrockStream_UnmappedReasonLeavesStatusUnset(t *testing.T) {
assert.Nil(t, terminal.Response.IncompleteDetails)
}

// TestFinalizeBedrockStream_ContentFilterIncomplete guards the streaming
// counterpart of the content-filter fix: when Bedrock's stopReason maps to
// content_filter / guardrail_intervened, the terminal SSE event must be
// response.incomplete carrying Status="incomplete" + IncompleteDetails.Reason
// "content_filter", so streaming consumers can detect the filtered turn instead
// of seeing a successful-looking response.completed.
func TestFinalizeBedrockStream_ContentFilterIncomplete(t *testing.T) {
for _, stopReason := range []string{"content_filter", "guardrail_intervened"} {
t.Run(stopReason, func(t *testing.T) {
state := bedrock.NewBedrockResponsesStreamState()
state.StopReason = schemas.Ptr(stopReason)
usage := &schemas.ResponsesResponseUsage{InputTokens: 5, OutputTokens: 0, TotalTokens: 5}

finalResponses := bedrock.FinalizeBedrockStream(state, 0, usage, nil)
require.NotEmpty(t, finalResponses)

terminal := finalResponses[len(finalResponses)-1]
assert.Equal(t, schemas.ResponsesStreamResponseTypeIncomplete, terminal.Type)
require.NotNil(t, terminal.Response)
require.NotNil(t, terminal.Response.Status)
assert.Equal(t, schemas.ResponsesResponseStatusIncomplete, *terminal.Response.Status)
require.NotNil(t, terminal.Response.IncompleteDetails)
assert.Equal(t, schemas.ResponsesResponseIncompleteReasonContentFilter, terminal.Response.IncompleteDetails.Reason)
})
}
}

// TestBifrostToBedrockStopReasonReverseMapping tests the reverse conversion
// (BifrostResponsesResponse.StopReason → BedrockConverseResponse.StopReason).
func TestBifrostToBedrockStopReasonReverseMapping(t *testing.T) {
Expand Down
16 changes: 14 additions & 2 deletions core/providers/bedrock/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,12 @@ func FinalizeBedrockStream(state *BedrockResponsesStreamState, sequenceNumber in
response.IncompleteDetails = &schemas.ResponsesResponseIncompleteDetails{
Reason: schemas.ResponsesResponseIncompleteReasonMaxOutputTokens,
}
case bedrockStopReasonContentFilter, bedrockStopReasonGuardrailIntervened:
terminalEventType = schemas.ResponsesStreamResponseTypeIncomplete
response.Status = schemas.Ptr(schemas.ResponsesResponseStatusIncomplete)
response.IncompleteDetails = &schemas.ResponsesResponseIncompleteDetails{
Reason: schemas.ResponsesResponseIncompleteReasonContentFilter,
}
case string(schemas.BifrostFinishReasonStop), string(schemas.BifrostFinishReasonToolCalls):
if response.Status == nil {
response.Status = schemas.Ptr(schemas.ResponsesResponseStatusCompleted)
Expand Down Expand Up @@ -2790,14 +2796,20 @@ func (response *BedrockConverseResponse) ToBifrostResponsesResponse(ctx *schemas
}
}
bifrostResp.StopReason = &stopReason
// Surface truncation via Status + IncompleteDetails per OpenAI's
// Responses-API contract; without these, truncations are silent.
// Surface truncation/filtering via Status + IncompleteDetails per OpenAI's
// Responses-API contract; without these, a content-filtered or truncated
// turn is indistinguishable from a genuine empty completion.
switch stopReason {
case string(schemas.BifrostFinishReasonLength):
bifrostResp.Status = schemas.Ptr(schemas.ResponsesResponseStatusIncomplete)
bifrostResp.IncompleteDetails = &schemas.ResponsesResponseIncompleteDetails{
Reason: schemas.ResponsesResponseIncompleteReasonMaxOutputTokens,
}
case bedrockStopReasonContentFilter, bedrockStopReasonGuardrailIntervened:
bifrostResp.Status = schemas.Ptr(schemas.ResponsesResponseStatusIncomplete)
bifrostResp.IncompleteDetails = &schemas.ResponsesResponseIncompleteDetails{
Reason: schemas.ResponsesResponseIncompleteReasonContentFilter,
}
case string(schemas.BifrostFinishReasonStop), string(schemas.BifrostFinishReasonToolCalls):
if bifrostResp.Status == nil {
bifrostResp.Status = schemas.Ptr(schemas.ResponsesResponseStatusCompleted)
Expand Down
8 changes: 8 additions & 0 deletions core/providers/bedrock/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ var (
}
)

// Bifrost-format stop reasons (post-convertBedrockStopReason) that map to a
// content-filtered outcome: "content_filtered" is remapped to "content_filter",
// while "guardrail_intervened" has no Bifrost equivalent and passes through as-is.
const (
bedrockStopReasonContentFilter = "content_filter"
bedrockStopReasonGuardrailIntervened = "guardrail_intervened"
)

// convertBedrockStopReason converts a Bedrock stop reason to Bifrost format.
func convertBedrockStopReason(stopReason string) string {
if reason, ok := bedrockFinishReasonToBifrost[stopReason]; ok {
Expand Down
4 changes: 3 additions & 1 deletion core/schemas/mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,9 @@ func sanitizeChatToolChoiceForFallback(toolChoice *ChatToolChoice, tools []ChatT
func responsesStatusFromChatFinishReason(finishReason string) (status string, incompleteDetails *ResponsesResponseIncompleteDetails, mapped bool) {
switch finishReason {
case string(BifrostFinishReasonLength):
return "incomplete", &ResponsesResponseIncompleteDetails{Reason: "max_output_tokens"}, true
return "incomplete", &ResponsesResponseIncompleteDetails{Reason: ResponsesResponseIncompleteReasonMaxOutputTokens}, true
case "content_filter", "guardrail_intervened":
return "incomplete", &ResponsesResponseIncompleteDetails{Reason: ResponsesResponseIncompleteReasonContentFilter}, true
case string(BifrostFinishReasonStop), string(BifrostFinishReasonToolCalls):
return "completed", nil, true
default:
Expand Down
28 changes: 27 additions & 1 deletion core/schemas/mux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ func TestToBifrostResponsesResponse_PrioritizesLengthAcrossChoices(t *testing.T)
}

func TestToBifrostResponsesResponse_UnknownFinishReasonLeavesStatusUnset(t *testing.T) {
unknown := "content_filter"
unknown := "some_unmapped_reason"
resp := (&BifrostChatResponse{
Choices: []BifrostResponseChoice{
{FinishReason: &unknown},
Expand All @@ -671,6 +671,32 @@ func TestToBifrostResponsesResponse_UnknownFinishReasonLeavesStatusUnset(t *test
}
}

func TestToBifrostResponsesResponse_MapsContentFilterToIncomplete(t *testing.T) {
for _, finish := range []string{"content_filter", "guardrail_intervened"} {
t.Run(finish, func(t *testing.T) {
fr := finish
resp := (&BifrostChatResponse{
Choices: []BifrostResponseChoice{
{FinishReason: &fr},
},
}).ToBifrostResponsesResponse()

if resp == nil {
t.Fatal("expected non-nil response")
}
if resp.Status == nil || *resp.Status != ResponsesResponseStatusIncomplete {
t.Fatalf("expected status %q, got %v", ResponsesResponseStatusIncomplete, resp.Status)
}
if resp.IncompleteDetails == nil || resp.IncompleteDetails.Reason != ResponsesResponseIncompleteReasonContentFilter {
t.Fatalf("expected incomplete_details.reason %q, got %+v", ResponsesResponseIncompleteReasonContentFilter, resp.IncompleteDetails)
}
if resp.StopReason == nil || *resp.StopReason != finish {
t.Fatalf("expected stop_reason %q, got %v", finish, resp.StopReason)
}
})
}
}

func TestToBifrostResponsesStreamResponse_IncludesFunctionCallsInCompletedOutput(t *testing.T) {
state := AcquireChatToResponsesStreamState()
defer ReleaseChatToResponsesStreamState(state)
Expand Down
Loading