Skip to content
Closed
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
1 change: 1 addition & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- fix: signal Bedrock max_output_tokens truncation on Responses API [@jeremym-tanium](https://github.com/jeremym-tanium)
- fix: add GLM-5.2 to models supporting max reasoning effort [@is911](https://github.com/is911)
- feat: add OpenCode gateway providers (Go, Zen) (thanks [@neta79](https://github.com/neta79)!)
- feat: add advisor tool compatibility for Claude
Expand Down
81 changes: 71 additions & 10 deletions core/providers/bedrock/bedrock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4594,17 +4594,19 @@ func TestBedrockStopReasonMappingResponsesPath(t *testing.T) {
t.Parallel()

tests := []struct {
name string
bedrockReason string
expectedBifrost string
name string
bedrockReason string
expectedBifrost string
expectedStatus string // "" means Status should be nil
expectedIncompleteDetails string // "" means IncompleteDetails should be nil
}{
{"EndTurn", "end_turn", "stop"},
{"MaxTokens", "max_tokens", "length"},
{"StopSequence", "stop_sequence", "stop"},
{"ToolUse", "tool_use", "tool_calls"},
{"ContentFiltered", "content_filtered", "content_filter"},
{"GuardrailIntervened", "guardrail_intervened", "guardrail_intervened"}, // no clean mapping — passes through
{"UnknownReason", "some_unknown_reason", "some_unknown_reason"}, // no clean mapping — passes through
{"EndTurn", "end_turn", "stop", "completed", ""},
{"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
}

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
Expand All @@ -4629,10 +4631,69 @@ func TestBedrockStopReasonMappingResponsesPath(t *testing.T) {
require.NotNil(t, bifrostResp.StopReason, "StopReason should be set")
assert.Equal(t, tt.expectedBifrost, *bifrostResp.StopReason,
"Bedrock stop reason %q should map to %q in responses path", tt.bedrockReason, tt.expectedBifrost)

// Status + IncompleteDetails mirror the OpenAI Responses-API spec.
// Truncation must surface as Status="incomplete" with the canonical
// IncompleteDetails.Reason; clean completions get Status="completed";
// unmapped reasons leave both unset (preserves prior behavior).
if tt.expectedStatus == "" {
assert.Nil(t, bifrostResp.Status, "Status must be nil for unmapped stop reasons")
assert.Nil(t, bifrostResp.IncompleteDetails, "IncompleteDetails must be nil for unmapped stop reasons")
} else {
require.NotNil(t, bifrostResp.Status, "Status must be set for mapped stop reason %q", tt.bedrockReason)
assert.Equal(t, tt.expectedStatus, *bifrostResp.Status)
}
if tt.expectedIncompleteDetails == "" {
assert.Nil(t, bifrostResp.IncompleteDetails)
} else {
require.NotNil(t, bifrostResp.IncompleteDetails)
assert.Equal(t, tt.expectedIncompleteDetails, bifrostResp.IncompleteDetails.Reason)
}
})
}
}

// TestFinalizeBedrockStream_MaxTokensTruncation guards the streaming counterpart:
// when Bedrock's stopReason is "max_tokens", the terminal SSE event must be
// response.incomplete (not response.completed) and the embedded response must
// carry Status="incomplete" + IncompleteDetails.Reason="max_output_tokens" so
// streaming consumers can detect truncation.
func TestFinalizeBedrockStream_MaxTokensTruncation(t *testing.T) {
state := bedrock.NewBedrockResponsesStreamState()
state.StopReason = schemas.Ptr("length") // mapped from bedrock's "max_tokens"
usage := &schemas.ResponsesResponseUsage{InputTokens: 30, OutputTokens: 15, TotalTokens: 45}

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

terminal := finalResponses[len(finalResponses)-1]
assert.Equal(t, schemas.ResponsesStreamResponseTypeIncomplete, terminal.Type,
"terminal event must be response.incomplete on max_output_tokens truncation")
require.NotNil(t, terminal.Response)
require.NotNil(t, terminal.Response.Status)
assert.Equal(t, "incomplete", *terminal.Response.Status)
require.NotNil(t, terminal.Response.IncompleteDetails)
assert.Equal(t, "max_output_tokens", terminal.Response.IncompleteDetails.Reason)
}

// TestFinalizeBedrockStream_CleanCompletionUnaffected verifies non-truncation
// stop reasons still produce response.completed with Status="completed".
func TestFinalizeBedrockStream_CleanCompletionUnaffected(t *testing.T) {
state := bedrock.NewBedrockResponsesStreamState()
state.StopReason = schemas.Ptr("stop")
usage := &schemas.ResponsesResponseUsage{InputTokens: 5, OutputTokens: 10, TotalTokens: 15}

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

terminal := finalResponses[len(finalResponses)-1]
assert.Equal(t, schemas.ResponsesStreamResponseTypeCompleted, terminal.Type)
require.NotNil(t, terminal.Response)
require.NotNil(t, terminal.Response.Status)
assert.Equal(t, "completed", *terminal.Response.Status)
assert.Nil(t, terminal.Response.IncompleteDetails)
}

// TestBifrostToBedrockStopReasonReverseMapping tests the reverse conversion
// (BifrostResponsesResponse.StopReason → BedrockConverseResponse.StopReason).
func TestBifrostToBedrockStopReasonReverseMapping(t *testing.T) {
Expand Down
28 changes: 27 additions & 1 deletion core/providers/bedrock/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -1510,8 +1510,21 @@ func FinalizeBedrockStream(state *BedrockResponsesStreamState, sequenceNumber in
}
}

// On truncation, emit response.incomplete (not response.completed) and
// set Status + IncompleteDetails per OpenAI's Responses-API contract.
terminalEventType := schemas.ResponsesStreamResponseTypeCompleted
if response.StopReason != nil && *response.StopReason == string(schemas.BifrostFinishReasonLength) {
terminalEventType = schemas.ResponsesStreamResponseTypeIncomplete
response.Status = schemas.Ptr("incomplete")
response.IncompleteDetails = &schemas.ResponsesResponseIncompleteDetails{
Reason: "max_output_tokens",
}
} else if response.Status == nil {
response.Status = schemas.Ptr("completed")
}

responses = append(responses, &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeCompleted,
Type: terminalEventType,
SequenceNumber: sequenceNumber + len(responses),
Response: response,
})
Expand Down Expand Up @@ -2660,6 +2673,19 @@ 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.
switch stopReason {
case string(schemas.BifrostFinishReasonLength):
bifrostResp.Status = schemas.Ptr("incomplete")
bifrostResp.IncompleteDetails = &schemas.ResponsesResponseIncompleteDetails{
Reason: "max_output_tokens",
}
case string(schemas.BifrostFinishReasonStop), string(schemas.BifrostFinishReasonToolCalls):
if bifrostResp.Status == nil {
bifrostResp.Status = schemas.Ptr("completed")
}
}
}

if response.Trace != nil {
Expand Down