From 3dfc3bcc233c04c604707b7863ed87e0a2babd67 Mon Sep 17 00:00:00 2001 From: Anuj Parihar Date: Wed, 12 Aug 2026 17:52:36 +0530 Subject: [PATCH] fix: content_filtered status fallback --- core/providers/bedrock/bedrock_test.go | 76 +++++++++++++++++++++++--- core/providers/bedrock/responses.go | 16 +++++- core/providers/bedrock/utils.go | 8 +++ core/schemas/mux.go | 4 +- core/schemas/mux_test.go | 28 +++++++++- 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/core/providers/bedrock/bedrock_test.go b/core/providers/bedrock/bedrock_test.go index 8d003770fdf..5bb0c6bdc94 100644 --- a/core/providers/bedrock/bedrock_test.go +++ b/core/providers/bedrock/bedrock_test.go @@ -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 @@ -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) @@ -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) @@ -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) { diff --git a/core/providers/bedrock/responses.go b/core/providers/bedrock/responses.go index 79fc9b639c8..05002381b52 100644 --- a/core/providers/bedrock/responses.go +++ b/core/providers/bedrock/responses.go @@ -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) @@ -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) diff --git a/core/providers/bedrock/utils.go b/core/providers/bedrock/utils.go index b8b03955b61..b20d826b137 100644 --- a/core/providers/bedrock/utils.go +++ b/core/providers/bedrock/utils.go @@ -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 { diff --git a/core/schemas/mux.go b/core/schemas/mux.go index 0df9a552de0..2573044e8e4 100644 --- a/core/schemas/mux.go +++ b/core/schemas/mux.go @@ -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: diff --git a/core/schemas/mux_test.go b/core/schemas/mux_test.go index ba6760f1f4e..030b634d64c 100644 --- a/core/schemas/mux_test.go +++ b/core/schemas/mux_test.go @@ -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}, @@ -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)