|
6 | 6 |
|
7 | 7 | import base64
|
8 | 8 | from pathlib import Path
|
9 |
| -from unittest.mock import Mock, patch |
| 9 | +from unittest.mock import AsyncMock, Mock, patch |
10 | 10 |
|
11 | 11 | import httpx
|
12 | 12 | import pytest
|
@@ -845,3 +845,307 @@ async def test_chat_completions_content_formatting(self):
|
845 | 845 | assert body["messages"] == expected_messages
|
846 | 846 | finally:
|
847 | 847 | await backend.process_shutdown()
|
| 848 | + |
| 849 | + @pytest.mark.regression |
| 850 | + @pytest.mark.asyncio |
| 851 | + async def test_openai_backend_validate_no_models_available(self): |
| 852 | + """Test validate method when no models are available. |
| 853 | +
|
| 854 | + ### WRITTEN BY AI ### |
| 855 | + """ |
| 856 | + backend = OpenAIHTTPBackend(target="http://test") |
| 857 | + await backend.process_startup() |
| 858 | + |
| 859 | + try: |
| 860 | + # Mock endpoints to fail, then available_models to return empty list |
| 861 | + def mock_get_fail(*args, **kwargs): |
| 862 | + raise httpx.HTTPStatusError("Error", request=Mock(), response=Mock()) |
| 863 | + |
| 864 | + with ( |
| 865 | + patch.object(backend._async_client, "get", side_effect=mock_get_fail), |
| 866 | + patch.object(backend, "available_models", return_value=[]), |
| 867 | + patch.object(backend, "text_completions", side_effect=mock_get_fail), |
| 868 | + pytest.raises( |
| 869 | + RuntimeError, |
| 870 | + match="No model available and could not set a default model", |
| 871 | + ), |
| 872 | + ): |
| 873 | + await backend.validate() |
| 874 | + finally: |
| 875 | + await backend.process_shutdown() |
| 876 | + |
| 877 | + @pytest.mark.sanity |
| 878 | + @pytest.mark.asyncio |
| 879 | + async def test_text_completions_streaming(self): |
| 880 | + """Test text_completions with streaming enabled.""" |
| 881 | + backend = OpenAIHTTPBackend(target="http://test", model="gpt-4") |
| 882 | + await backend.process_startup() |
| 883 | + |
| 884 | + try: |
| 885 | + # Mock streaming response |
| 886 | + mock_stream = Mock() |
| 887 | + mock_stream.raise_for_status = Mock() |
| 888 | + |
| 889 | + async def mock_aiter_lines(): |
| 890 | + lines = [ |
| 891 | + 'data: {"choices":[{"text":"Hello"}], "usage":{"prompt_tokens":5,"completion_tokens":1}}', # noqa: E501 |
| 892 | + 'data: {"choices":[{"text":" world"}], "usage":{"prompt_tokens":5,"completion_tokens":2}}', # noqa: E501 |
| 893 | + 'data: {"choices":[{"text":"!"}], "usage":{"prompt_tokens":5,"completion_tokens":3}}', # noqa: E501 |
| 894 | + "data: [DONE]", |
| 895 | + ] |
| 896 | + for line in lines: |
| 897 | + yield line |
| 898 | + |
| 899 | + mock_stream.aiter_lines = mock_aiter_lines |
| 900 | + |
| 901 | + mock_client_stream = AsyncMock() |
| 902 | + mock_client_stream.__aenter__ = AsyncMock(return_value=mock_stream) |
| 903 | + mock_client_stream.__aexit__ = AsyncMock(return_value=None) |
| 904 | + |
| 905 | + with patch.object( |
| 906 | + backend._async_client, "stream", return_value=mock_client_stream |
| 907 | + ): |
| 908 | + results = [] |
| 909 | + async for result in backend.text_completions( |
| 910 | + prompt="test prompt", request_id="req-123", stream_response=True |
| 911 | + ): |
| 912 | + results.append(result) |
| 913 | + |
| 914 | + # Should get initial None, then tokens, then final with usage |
| 915 | + assert len(results) >= 3 |
| 916 | + assert results[0] == (None, None) # Initial yield |
| 917 | + assert all( |
| 918 | + isinstance(result[0], str) for result in results[1:] |
| 919 | + ) # Has text content |
| 920 | + assert all( |
| 921 | + isinstance(result[1], UsageStats) for result in results[1:] |
| 922 | + ) # Has usage stats |
| 923 | + assert all( |
| 924 | + result[1].output_tokens == i for i, result in enumerate(results[1:], 1) |
| 925 | + ) |
| 926 | + finally: |
| 927 | + await backend.process_shutdown() |
| 928 | + |
| 929 | + @pytest.mark.sanity |
| 930 | + @pytest.mark.asyncio |
| 931 | + async def test_chat_completions_streaming(self): |
| 932 | + """Test chat_completions with streaming enabled. |
| 933 | +
|
| 934 | + ### WRITTEN BY AI ### |
| 935 | + """ |
| 936 | + backend = OpenAIHTTPBackend(target="http://test", model="gpt-4") |
| 937 | + await backend.process_startup() |
| 938 | + |
| 939 | + try: |
| 940 | + # Mock streaming response |
| 941 | + mock_stream = Mock() |
| 942 | + mock_stream.raise_for_status = Mock() |
| 943 | + |
| 944 | + async def mock_aiter_lines(): |
| 945 | + lines = [ |
| 946 | + 'data: {"choices":[{"delta":{"content":"Hi"}}]}', |
| 947 | + 'data: {"choices":[{"delta":{"content":" there"}}]}', |
| 948 | + 'data: {"choices":[{"delta":{"content":"!"}}]}', |
| 949 | + 'data: {"usage":{"prompt_tokens":3,"completion_tokens":3}}', |
| 950 | + "data: [DONE]", |
| 951 | + ] |
| 952 | + for line in lines: |
| 953 | + yield line |
| 954 | + |
| 955 | + mock_stream.aiter_lines = mock_aiter_lines |
| 956 | + |
| 957 | + mock_client_stream = AsyncMock() |
| 958 | + mock_client_stream.__aenter__ = AsyncMock(return_value=mock_stream) |
| 959 | + mock_client_stream.__aexit__ = AsyncMock(return_value=None) |
| 960 | + |
| 961 | + with patch.object( |
| 962 | + backend._async_client, "stream", return_value=mock_client_stream |
| 963 | + ): |
| 964 | + results = [] |
| 965 | + async for result in backend.chat_completions( |
| 966 | + content="Hello", request_id="req-456", stream_response=True |
| 967 | + ): |
| 968 | + results.append(result) |
| 969 | + |
| 970 | + # Should get initial None, then deltas, then final with usage |
| 971 | + assert len(results) >= 3 |
| 972 | + assert results[0] == (None, None) # Initial yield |
| 973 | + assert any(result[0] for result in results if result[0]) # Has content |
| 974 | + assert any(result[1] for result in results if result[1]) # Has usage stats |
| 975 | + finally: |
| 976 | + await backend.process_shutdown() |
| 977 | + |
| 978 | + @pytest.mark.regression |
| 979 | + @pytest.mark.asyncio |
| 980 | + async def test_streaming_response_edge_cases(self): |
| 981 | + """Test streaming response edge cases for line processing. |
| 982 | +
|
| 983 | + ### WRITTEN BY AI ### |
| 984 | + """ |
| 985 | + backend = OpenAIHTTPBackend(target="http://test", model="gpt-4") |
| 986 | + await backend.process_startup() |
| 987 | + |
| 988 | + try: |
| 989 | + # Mock streaming response with edge cases |
| 990 | + mock_stream = Mock() |
| 991 | + mock_stream.raise_for_status = Mock() |
| 992 | + |
| 993 | + async def mock_aiter_lines(): |
| 994 | + lines = [ |
| 995 | + "", # Empty line |
| 996 | + " ", # Whitespace only |
| 997 | + "not data line", # Line without data prefix |
| 998 | + 'data: {"choices":[{"text":"Hello"}]}', # Valid data |
| 999 | + "data: [DONE]", # End marker |
| 1000 | + ] |
| 1001 | + for line in lines: |
| 1002 | + yield line |
| 1003 | + |
| 1004 | + mock_stream.aiter_lines = mock_aiter_lines |
| 1005 | + |
| 1006 | + mock_client_stream = AsyncMock() |
| 1007 | + mock_client_stream.__aenter__ = AsyncMock(return_value=mock_stream) |
| 1008 | + mock_client_stream.__aexit__ = AsyncMock(return_value=None) |
| 1009 | + |
| 1010 | + with patch.object( |
| 1011 | + backend._async_client, "stream", return_value=mock_client_stream |
| 1012 | + ): |
| 1013 | + results = [] |
| 1014 | + async for result in backend.text_completions( |
| 1015 | + prompt="test", request_id="req-123", stream_response=True |
| 1016 | + ): |
| 1017 | + results.append(result) |
| 1018 | + |
| 1019 | + # Should get initial None and the valid response |
| 1020 | + assert len(results) == 2 |
| 1021 | + assert results[0] == (None, None) |
| 1022 | + assert results[1][0] == "Hello" |
| 1023 | + finally: |
| 1024 | + await backend.process_shutdown() |
| 1025 | + |
| 1026 | + @pytest.mark.sanity |
| 1027 | + def test_openai_backend_get_chat_message_media_item_jpeg_file(self): |
| 1028 | + """Test _get_chat_message_media_item with JPEG file path. |
| 1029 | +
|
| 1030 | + ### WRITTEN BY AI ### |
| 1031 | + """ |
| 1032 | + backend = OpenAIHTTPBackend(target="http://test") |
| 1033 | + |
| 1034 | + # Create a mock Path object for JPEG file |
| 1035 | + mock_jpeg_path = Mock(spec=Path) |
| 1036 | + mock_jpeg_path.suffix.lower.return_value = ".jpg" |
| 1037 | + |
| 1038 | + # Mock Image.open to return a mock image |
| 1039 | + mock_image = Mock(spec=Image.Image) |
| 1040 | + mock_image.tobytes.return_value = b"fake_jpeg_data" |
| 1041 | + |
| 1042 | + with patch("guidellm.backend.openai.Image.open", return_value=mock_image): |
| 1043 | + result = backend._get_chat_message_media_item(mock_jpeg_path) |
| 1044 | + |
| 1045 | + expected_data = base64.b64encode(b"fake_jpeg_data").decode("utf-8") |
| 1046 | + expected = { |
| 1047 | + "type": "image", |
| 1048 | + "image": {"url": f"data:image/jpeg;base64,{expected_data}"}, |
| 1049 | + } |
| 1050 | + assert result == expected |
| 1051 | + |
| 1052 | + @pytest.mark.sanity |
| 1053 | + def test_openai_backend_get_chat_message_media_item_wav_file(self): |
| 1054 | + """Test _get_chat_message_media_item with WAV file path. |
| 1055 | +
|
| 1056 | + ### WRITTEN BY AI ### |
| 1057 | + """ |
| 1058 | + backend = OpenAIHTTPBackend(target="http://test") |
| 1059 | + |
| 1060 | + # Create a mock Path object for WAV file |
| 1061 | + mock_wav_path = Mock(spec=Path) |
| 1062 | + mock_wav_path.suffix.lower.return_value = ".wav" |
| 1063 | + mock_wav_path.read_bytes.return_value = b"fake_wav_data" |
| 1064 | + |
| 1065 | + result = backend._get_chat_message_media_item(mock_wav_path) |
| 1066 | + |
| 1067 | + expected_data = base64.b64encode(b"fake_wav_data").decode("utf-8") |
| 1068 | + expected = { |
| 1069 | + "type": "input_audio", |
| 1070 | + "input_audio": {"data": expected_data, "format": "wav"}, |
| 1071 | + } |
| 1072 | + assert result == expected |
| 1073 | + |
| 1074 | + @pytest.mark.sanity |
| 1075 | + def test_openai_backend_get_chat_messages_with_pil_image(self): |
| 1076 | + """Test _get_chat_messages with PIL Image in content list. |
| 1077 | +
|
| 1078 | + ### WRITTEN BY AI ### |
| 1079 | + """ |
| 1080 | + backend = OpenAIHTTPBackend(target="http://test") |
| 1081 | + |
| 1082 | + # Create a mock PIL Image |
| 1083 | + mock_image = Mock(spec=Image.Image) |
| 1084 | + mock_image.tobytes.return_value = b"fake_image_bytes" |
| 1085 | + |
| 1086 | + content = ["Hello", mock_image, "world"] |
| 1087 | + |
| 1088 | + result = backend._get_chat_messages(content) |
| 1089 | + |
| 1090 | + # Should have one user message with mixed content |
| 1091 | + assert len(result) == 1 |
| 1092 | + assert result[0]["role"] == "user" |
| 1093 | + assert len(result[0]["content"]) == 3 |
| 1094 | + |
| 1095 | + # Check text items |
| 1096 | + assert result[0]["content"][0] == {"type": "text", "text": "Hello"} |
| 1097 | + assert result[0]["content"][2] == {"type": "text", "text": "world"} |
| 1098 | + |
| 1099 | + # Check image item |
| 1100 | + image_item = result[0]["content"][1] |
| 1101 | + assert image_item["type"] == "image" |
| 1102 | + assert "data:image/jpeg;base64," in image_item["image"]["url"] |
| 1103 | + |
| 1104 | + @pytest.mark.regression |
| 1105 | + @pytest.mark.asyncio |
| 1106 | + async def test_resolve_timing_edge_cases(self): |
| 1107 | + """Test resolve method timing edge cases. |
| 1108 | +
|
| 1109 | + ### WRITTEN BY AI ### |
| 1110 | + """ |
| 1111 | + backend = OpenAIHTTPBackend(target="http://test") |
| 1112 | + await backend.process_startup() |
| 1113 | + |
| 1114 | + try: |
| 1115 | + request = GenerationRequest( |
| 1116 | + content="test prompt", |
| 1117 | + request_type="text_completions", |
| 1118 | + constraints={"output_tokens": 50}, |
| 1119 | + ) |
| 1120 | + request_info = ScheduledRequestInfo( |
| 1121 | + request_id="test-id", |
| 1122 | + status="pending", |
| 1123 | + scheduler_node_id=1, |
| 1124 | + scheduler_process_id=1, |
| 1125 | + scheduler_start_time=123.0, |
| 1126 | + request_timings=GenerationRequestTimings(), |
| 1127 | + ) |
| 1128 | + |
| 1129 | + # Mock text_completions to test timing edge cases |
| 1130 | + async def mock_text_completions(*args, **kwargs): |
| 1131 | + yield None, None # Initial yield - tests line 343 |
| 1132 | + yield "token1", None # First token |
| 1133 | + yield "token2", UsageStats(prompt_tokens=10, output_tokens=2) # Final |
| 1134 | + |
| 1135 | + with patch.object( |
| 1136 | + backend, "text_completions", side_effect=mock_text_completions |
| 1137 | + ): |
| 1138 | + responses = [] |
| 1139 | + async for response, info in backend.resolve(request, request_info): |
| 1140 | + responses.append((response, info)) |
| 1141 | + |
| 1142 | + # Check that timing was properly set |
| 1143 | + final_response, final_info = responses[-1] |
| 1144 | + assert final_info.request_timings.request_start is not None |
| 1145 | + assert final_info.request_timings.first_iteration is not None |
| 1146 | + assert final_info.request_timings.last_iteration is not None |
| 1147 | + assert final_info.request_timings.request_end is not None |
| 1148 | + assert final_response.delta is None # Tests line 362 |
| 1149 | + |
| 1150 | + finally: |
| 1151 | + await backend.process_shutdown() |
0 commit comments