diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 39008d802b..cf2ee10c6e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -6616,6 +6616,20 @@ "supports_tool_choice": true }, "cerebras/zai-glm-4.6": { + "deprecation_date": "2026-01-20", + "input_cost_per_token": 2.25e-06, + "litellm_provider": "cerebras", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "source": "https://www.cerebras.ai/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "cerebras/zai-glm-4.7": { "input_cost_per_token": 2.25e-06, "litellm_provider": "cerebras", "max_input_tokens": 128000, diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index c2912558ca..467c57c33d 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1216,11 +1216,11 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject): item_type = getattr(output_item, "type", None) content = getattr(output_item, "content", []) - if item_type == "message": + if item_type == "message" and content: for content_item in content: if isinstance(content_item, dict): content_type = content_item.get("type") - text = content_item.get("text", "") + text = content_item.get("text", "") or "" else: content_type = getattr(content_item, "type", None) text = getattr(content_item, "text", "") or "" diff --git a/tests/test_litellm/responses/test_null_test_fix.py b/tests/test_litellm/responses/test_null_test_fix.py new file mode 100644 index 0000000000..702770ac5a --- /dev/null +++ b/tests/test_litellm/responses/test_null_test_fix.py @@ -0,0 +1,291 @@ +""" +Test for fixing null text values in output_text content blocks. + +This test verifies that LiteLLM properly handles streaming responses where +text content is None, preventing TypeErrors in downstream OpenAI-compatible SDKs. + +Related issue: When using LiteLLM as an OpenAI-compatible proxy for self-hosted +models, streamed responses can contain output_text content blocks where text is null. +These responses are forwarded unchanged to downstream SDKs which expect text to always +be a string (or omitted), causing TypeErrors. +""" + +import pytest + +from litellm.types.llms.openai import ResponsesAPIResponse + + +class TestNullTextHandling: + """Test suite for handling None/null text values in responses.""" + + def test_output_text_with_none_text_dict_access(self): + """ + Test that output_text property handles None text values correctly when using dict access. + + This simulates the scenario where a self-hosted model returns a response with + text: null in the content block. + """ + # Create a response with None text value (simulating gpt-oss-120b behavior) + response_data = { + "id": "resp_test123", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_test123", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": None, # This is the problematic case + "annotations": [] + } + ] + } + ] + } + + response = ResponsesAPIResponse(**response_data) + + # Should not raise TypeError and should return empty string + assert response.output_text == "" + + def test_output_text_with_none_text_object_access(self): + """ + Test that output_text property handles None text values correctly. + + This test verifies the object access path (getattr) in the output_text property. + """ + response_data = { + "id": "resp_test456", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_test456", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": None, # This is the problematic case + "annotations": [] + } + ] + } + ] + } + + response = ResponsesAPIResponse(**response_data) + + # Should not raise TypeError and should return empty string + assert response.output_text == "" + + def test_output_text_with_mixed_none_and_valid_text(self): + """ + Test that output_text properly concatenates when some text values are None. + """ + response_data = { + "id": "resp_test789", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_test789", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello ", + "annotations": [] + }, + { + "type": "output_text", + "text": None, # Should be treated as empty string + "annotations": [] + }, + { + "type": "output_text", + "text": "world!", + "annotations": [] + } + ] + } + ] + } + + response = ResponsesAPIResponse(**response_data) + + # Should concatenate non-None values, treating None as empty string + assert response.output_text == "Hello world!" + + def test_output_text_with_empty_string(self): + """ + Test that empty strings are handled correctly (baseline test). + """ + response_data = { + "id": "resp_test_empty", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_test_empty", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "", + "annotations": [] + } + ] + } + ] + } + + response = ResponsesAPIResponse(**response_data) + + # Should return empty string + assert response.output_text == "" + + def test_output_text_with_valid_text(self): + """ + Test that valid text values work correctly (baseline test). + """ + response_data = { + "id": "resp_test_valid", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_test_valid", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "This is a valid response", + "annotations": [] + } + ] + } + ] + } + + response = ResponsesAPIResponse(**response_data) + + # Should return the text as-is + assert response.output_text == "This is a valid response" + + def test_output_text_no_output_text_content(self): + """ + Test that responses without output_text content return empty string. + """ + response_data = { + "id": "resp_test_no_content", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_test_no_content", + "status": "completed", + "role": "assistant", + "content": [] + } + ] + } + + response = ResponsesAPIResponse(**response_data) + + # Should return empty string when no output_text content exists + assert response.output_text == "" + + +class TestStreamingIteratorTextHandling: + """Test suite for streaming iterator text handling.""" + + def test_content_part_added_event_has_empty_string_text(self): + """ + Test that ContentPartAddedEvent is created with empty string, not None. + """ + from unittest.mock import Mock + + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + from litellm.types.llms.openai import ( + ResponseInputParam, + ResponsesAPIOptionalRequestParams, + ) + + # Create a mock stream wrapper + mock_wrapper = Mock() + mock_wrapper.logging_obj = Mock() + + iterator = LiteLLMCompletionStreamingIterator( + model="gpt-oss-120b", + litellm_custom_stream_wrapper=mock_wrapper, + request_input="test input", + responses_api_request={}, + ) + + event = iterator.create_content_part_added_event() + + # Verify that the part has text field set to empty string, not None + part_dict = event.part.model_dump() if hasattr(event.part, 'model_dump') else dict(event.part) + assert "text" in part_dict + assert part_dict["text"] == "" + assert part_dict["text"] is not None + + def test_delta_string_from_none_content(self): + """ + Test that _get_delta_string_from_streaming_choices returns empty string for None content. + """ + from unittest.mock import Mock + + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + from litellm.types.utils import Delta, StreamingChoices + + # Create a mock stream wrapper + mock_wrapper = Mock() + mock_wrapper.logging_obj = Mock() + + iterator = LiteLLMCompletionStreamingIterator( + model="gpt-oss-120b", + litellm_custom_stream_wrapper=mock_wrapper, + request_input="test input", + responses_api_request={}, + ) + + # Create a choice with None content + choice = StreamingChoices( + index=0, + delta=Delta(content=None, role="assistant"), + finish_reason=None + ) + + result = iterator._get_delta_string_from_streaming_choices([choice]) + + # Should return empty string, not None + assert result == "" + assert result is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])