diff --git a/core/providers/openai/openai.go b/core/providers/openai/openai.go index 1c15086732f..4032a5a9456 100644 --- a/core/providers/openai/openai.go +++ b/core/providers/openai/openai.go @@ -1101,6 +1101,7 @@ func HandleOpenAIChatCompletionStreaming( var finishReason *string var messageID string + forwardedTerminalFinishReason := false for { // If context was cancelled/timed out, let defer handle it @@ -1212,7 +1213,7 @@ func HandleOpenAIChatCompletionStreaming( response.ExtraFields.RawResponse = jsonData } - if response.Type == schemas.ResponsesStreamResponseTypeCompleted { + if response.Type == schemas.ResponsesStreamResponseTypeCompleted || response.Type == schemas.ResponsesStreamResponseTypeIncomplete { // Set raw request if enabled if sendBackRawRequest { providerUtils.ParseAndSetRawRequest(&response.ExtraFields, jsonBody) @@ -1278,7 +1279,6 @@ func HandleOpenAIChatCompletionStreaming( if choice.FinishReason != nil && *choice.FinishReason != "" { // Collect finish reason and send at the end of the stream finishReason = choice.FinishReason - response.Choices[0].FinishReason = nil } if response.ID != "" && messageID == "" { @@ -1293,6 +1293,9 @@ func HandleOpenAIChatCompletionStreaming( len(choice.ChatStreamResponseChoice.Delta.ReasoningDetails) > 0 || choice.ChatStreamResponseChoice.Delta.Audio != nil || len(choice.ChatStreamResponseChoice.Delta.ToolCalls) > 0) { + if choice.FinishReason != nil && *choice.FinishReason != "" { + forwardedTerminalFinishReason = true + } chunkIndex++ response.ExtraFields.RequestType = schemas.ChatCompletionStreamRequest @@ -1317,7 +1320,11 @@ func HandleOpenAIChatCompletionStreaming( } if !isResponsesToChatCompletionsFallback { - response := providerUtils.CreateBifrostChatCompletionChunkResponse(messageID, usage, finishReason, chunkIndex, streamRequestType, providerName, request.Model) + finalFinishReason := finishReason + if forwardedTerminalFinishReason { + finalFinishReason = nil + } + response := providerUtils.CreateBifrostChatCompletionChunkResponse(messageID, usage, finalFinishReason, chunkIndex, streamRequestType, providerName, request.Model) if postResponseConverter != nil { response = postResponseConverter(response) } diff --git a/core/schemas/mux.go b/core/schemas/mux.go index ae026c99107..791f1911d9a 100644 --- a/core/schemas/mux.go +++ b/core/schemas/mux.go @@ -1142,6 +1142,38 @@ func sanitizeChatToolChoiceForFallback(toolChoice *ChatToolChoice, tools []ChatT // RESPONSE CONVERSION METHODS // ============================================================================= +func responsesStatusFromChatFinishReason(finishReason string) (status string, incompleteDetails *ResponsesResponseIncompleteDetails, mapped bool) { + switch finishReason { + case string(BifrostFinishReasonLength): + return "incomplete", &ResponsesResponseIncompleteDetails{Reason: "max_output_tokens"}, true + case string(BifrostFinishReasonStop), string(BifrostFinishReasonToolCalls): + return "completed", nil, true + default: + return "", nil, false + } +} + +func responsesTerminalFromChatFinishReason(finishReason *string) (eventType ResponsesStreamResponseType, status string, incompleteDetails *ResponsesResponseIncompleteDetails) { + // Unknown/empty finish reasons preserve prior behavior: treat as completed. + eventType = ResponsesStreamResponseTypeCompleted + status = "completed" + + if finishReason == nil || *finishReason == "" { + return eventType, status, nil + } + + mappedStatus, mappedIncompleteDetails, mapped := responsesStatusFromChatFinishReason(*finishReason) + if !mapped { + return eventType, status, nil + } + + if mappedStatus == "incomplete" { + eventType = ResponsesStreamResponseTypeIncomplete + } + + return eventType, mappedStatus, mappedIncompleteDetails +} + // ToBifrostResponsesResponse converts the BifrostChatResponse to BifrostResponsesResponse format // This converts Chat-style fields (Choices) to Responses API format func (cr *BifrostChatResponse) ToBifrostResponsesResponse() *BifrostResponsesResponse { @@ -1179,6 +1211,28 @@ func (cr *BifrostChatResponse) ToBifrostResponsesResponse() *BifrostResponsesRes responsesResp.Usage = cr.Usage.ToResponsesResponseUsage() } + // Map finish reason to Responses status. + hasCompletedFinishReason := false + for _, choice := range cr.Choices { + if choice.FinishReason == nil || *choice.FinishReason == "" { + continue + } + status, incompleteDetails, mapped := responsesStatusFromChatFinishReason(*choice.FinishReason) + if !mapped { + continue + } + if status == "incomplete" { + responsesResp.Status = Ptr(status) + responsesResp.IncompleteDetails = incompleteDetails + hasCompletedFinishReason = false + break + } + hasCompletedFinishReason = true + } + if responsesResp.Status == nil && hasCompletedFinishReason { + responsesResp.Status = Ptr("completed") + } + // Copy other relevant fields responsesResp.ExtraFields = cr.ExtraFields responsesResp.ExtraFields.RequestType = ResponsesRequest @@ -1709,6 +1763,8 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes // Check if this is a completion chunk with finish_reason if choice.FinishReason != nil { + terminalEventType, terminalStatus, terminalIncompleteDetails := responsesTerminalFromChatFinishReason(choice.FinishReason) + // Close text item if still open (regardless of whether it has content, to support reasoning-only responses) if state.TextItemAdded && !state.TextItemClosed { outputIndex := 0 @@ -1748,14 +1804,14 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes state.SequenceNumber++ // Emit output_item.done - statusCompleted := "completed" + statusFinal := terminalStatus messageType := ResponsesMessageTypeMessage role := ResponsesInputMessageRoleAssistant textType := ResponsesOutputMessageContentTypeText doneItem := &ResponsesMessage{ Type: &messageType, Role: &role, - Status: &statusCompleted, + Status: &statusFinal, Content: &ResponsesMessageContent{ ContentBlocks: []ResponsesMessageContentBlock{ { @@ -1807,7 +1863,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes state.SequenceNumber++ // Emit output_item.done for function call - statusCompleted := "completed" + statusFinal := terminalStatus messageType := ResponsesMessageTypeFunctionCall callName, hasName := state.ToolCallNames[toolCallID] var callNamePtr *string @@ -1817,7 +1873,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes argsValue := args outputItemDone := &ResponsesMessage{ Type: &messageType, - Status: &statusCompleted, + Status: &statusFinal, ResponsesToolMessage: &ResponsesToolMessage{ CallID: &toolCallID, Name: callNamePtr, @@ -1839,23 +1895,27 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes } } - // Emit response.completed + // Emit terminal response event. var usage *ResponsesResponseUsage if cr.Usage != nil { usage = cr.Usage.ToResponsesResponseUsage() } + responseStatus := terminalStatus + response := &BifrostResponsesResponse{ - ID: state.MessageID, - CreatedAt: state.CreatedAt, - Usage: usage, + ID: state.MessageID, + CreatedAt: state.CreatedAt, + Usage: usage, + Status: &responseStatus, + IncompleteDetails: terminalIncompleteDetails, } if state.Model != nil { response.Model = *state.Model } if state.TextItemAdded { - statusCompleted := "completed" + statusFinal := terminalStatus messageType := ResponsesMessageTypeMessage role := ResponsesInputMessageRoleAssistant textType := ResponsesOutputMessageContentTypeText @@ -1865,7 +1925,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes msg := ResponsesMessage{ Type: &messageType, Role: &role, - Status: &statusCompleted, + Status: &statusFinal, Content: &ResponsesMessageContent{ ContentBlocks: []ResponsesMessageContentBlock{ { @@ -1886,7 +1946,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes } responses = append(responses, &BifrostResponsesStreamResponse{ - Type: ResponsesStreamResponseTypeCompleted, + Type: terminalEventType, SequenceNumber: state.SequenceNumber, Response: response, ExtraFields: cr.ExtraFields, diff --git a/core/schemas/mux_test.go b/core/schemas/mux_test.go index 74bfe55d568..faf149fd724 100644 --- a/core/schemas/mux_test.go +++ b/core/schemas/mux_test.go @@ -319,3 +319,143 @@ func TestToBifrostResponsesStreamResponse_PopulatesFinalDoneTextAndCompletedOutp t.Fatalf("expected completed output text %q, got %q", "Hello world", *msg.Content.ContentBlocks[0].Text) } } + +func TestToBifrostResponsesResponse_MapsLengthToIncomplete(t *testing.T) { + length := string(BifrostFinishReasonLength) + resp := (&BifrostChatResponse{ + Choices: []BifrostResponseChoice{ + {FinishReason: &length}, + }, + }).ToBifrostResponsesResponse() + + if resp == nil || resp.Status == nil { + t.Fatal("expected status to be set") + } + if *resp.Status != "incomplete" { + t.Fatalf("expected status %q, got %q", "incomplete", *resp.Status) + } + if resp.IncompleteDetails == nil { + t.Fatal("expected incomplete_details to be set") + } + if resp.IncompleteDetails.Reason != "max_output_tokens" { + t.Fatalf("expected incomplete_details.reason %q, got %q", "max_output_tokens", resp.IncompleteDetails.Reason) + } +} + +func TestToBifrostResponsesResponse_MapsToolCallsToCompleted(t *testing.T) { + toolCalls := string(BifrostFinishReasonToolCalls) + resp := (&BifrostChatResponse{ + Choices: []BifrostResponseChoice{ + {FinishReason: &toolCalls}, + }, + }).ToBifrostResponsesResponse() + + if resp == nil || resp.Status == nil { + t.Fatal("expected status to be set") + } + if *resp.Status != "completed" { + t.Fatalf("expected status %q, got %q", "completed", *resp.Status) + } + if resp.IncompleteDetails != nil { + t.Fatal("expected incomplete_details to be nil") + } +} + +func TestToBifrostResponsesResponse_PrioritizesLengthAcrossChoices(t *testing.T) { + stop := string(BifrostFinishReasonStop) + length := string(BifrostFinishReasonLength) + resp := (&BifrostChatResponse{ + Choices: []BifrostResponseChoice{ + {FinishReason: &stop}, + {FinishReason: &length}, + }, + }).ToBifrostResponsesResponse() + + if resp == nil || resp.Status == nil { + t.Fatal("expected status to be set") + } + if *resp.Status != "incomplete" { + t.Fatalf("expected status %q, got %q", "incomplete", *resp.Status) + } + if resp.IncompleteDetails == nil || resp.IncompleteDetails.Reason != "max_output_tokens" { + t.Fatal("expected max_output_tokens incomplete_details") + } +} + +func TestToBifrostResponsesResponse_UnknownFinishReasonLeavesStatusUnset(t *testing.T) { + unknown := "content_filter" + resp := (&BifrostChatResponse{ + Choices: []BifrostResponseChoice{ + {FinishReason: &unknown}, + }, + }).ToBifrostResponsesResponse() + + if resp == nil { + t.Fatal("expected non-nil response") + } + if resp.Status != nil { + t.Fatalf("expected status to be nil, got %q", *resp.Status) + } + if resp.IncompleteDetails != nil { + t.Fatal("expected incomplete_details to be nil") + } +} + +func TestToBifrostResponsesStreamResponse_MapsLengthToIncompleteEvent(t *testing.T) { + state := AcquireChatToResponsesStreamState() + defer ReleaseChatToResponsesStreamState(state) + + makeChunk := func(role *string, content *string, finishReason *string) *BifrostChatResponse { + return &BifrostChatResponse{ + ID: "chatcmpl-test", + Model: "test-model", + Choices: []BifrostResponseChoice{ + { + FinishReason: finishReason, + ChatStreamResponseChoice: &ChatStreamResponseChoice{ + Delta: &ChatStreamResponseChoiceDelta{ + Role: role, + Content: content, + }, + }, + }, + }, + } + } + + role := string(ChatMessageRoleAssistant) + part := "Hello" + length := string(BifrostFinishReasonLength) + + var all []*BifrostResponsesStreamResponse + all = append(all, makeChunk(&role, nil, nil).ToBifrostResponsesStreamResponse(state)...) + all = append(all, makeChunk(nil, &part, nil).ToBifrostResponsesStreamResponse(state)...) + all = append(all, makeChunk(nil, nil, &length).ToBifrostResponsesStreamResponse(state)...) + + var completed *BifrostResponsesStreamResponse + var incomplete *BifrostResponsesStreamResponse + for _, evt := range all { + if evt == nil { + continue + } + if evt.Type == ResponsesStreamResponseTypeCompleted { + completed = evt + } + if evt.Type == ResponsesStreamResponseTypeIncomplete { + incomplete = evt + } + } + + if completed != nil { + t.Fatal("did not expect response.completed for finish_reason=length") + } + if incomplete == nil || incomplete.Response == nil { + t.Fatal("expected response.incomplete with response payload") + } + if incomplete.Response.Status == nil || *incomplete.Response.Status != "incomplete" { + t.Fatal("expected terminal response status to be incomplete") + } + if incomplete.Response.IncompleteDetails == nil || incomplete.Response.IncompleteDetails.Reason != "max_output_tokens" { + t.Fatal("expected incomplete_details.reason to be max_output_tokens") + } +}