diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 4bed171df7..ad2af41827 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -72,7 +72,7 @@ def parse_response( type_=cast(Any, ParsedResponseOutputText)[solved_t], value={ **item.to_dict(), - "parsed": parse_text(item.text, text_format=text_format), + "parsed": parse_text(item.text, text_format=text_format, response=response), }, ) ) @@ -135,20 +135,53 @@ def parse_response( ) -def parse_text(text: str, text_format: type[TextFormatT] | Omit) -> TextFormatT | None: +def parse_text( + text: str, + text_format: type[TextFormatT] | Omit, + response: Response | ParsedResponse[object] | None = None, +) -> TextFormatT | None: if not is_given(text_format): return None - if is_basemodel_type(text_format): - return cast(TextFormatT, model_parse_json(text_format, text)) - - if is_dataclass_like_type(text_format): - if PYDANTIC_V1: - raise TypeError(f"Non BaseModel types are only supported with Pydantic v2 - {text_format}") - - return pydantic.TypeAdapter(text_format).validate_json(text) - - raise TypeError(f"Unable to automatically parse response format type {text_format}") + try: + if is_basemodel_type(text_format): + return cast(TextFormatT, model_parse_json(text_format, text)) + + if is_dataclass_like_type(text_format): + if PYDANTIC_V1: + raise TypeError(f"Non BaseModel types are only supported with Pydantic v2 - {text_format}") + + return pydantic.TypeAdapter(text_format).validate_json(text) + + raise TypeError(f"Unable to automatically parse response format type {text_format}") + except (pydantic.ValidationError, json.JSONDecodeError) as e: + # Check if this is due to content moderation/filtering + if response and getattr(response, "incomplete_details", None): + incomplete_details = response.incomplete_details + if incomplete_details and getattr(incomplete_details, "reason", None) == "content_filter": + from ..._exceptions import ContentFilterFinishReasonError + + raise ContentFilterFinishReasonError() from e + + # For other validation errors, raise a more helpful exception + from ..._exceptions import APIResponseValidationError + + error_msg = ( + f"Failed to parse response content as {text_format.__name__ if hasattr(text_format, '__name__') else text_format}. " + f"The model returned text that doesn't match the expected schema. " + f"Text received: {text[:200]}{'...' if len(text) > 200 else ''}" + ) + + # Create a minimal request object for the exception + # In practice, this should ideally come from the actual request, but we don't have access here + import httpx + + request = httpx.Request("POST", "https://api.openai.com/v1/responses") + raise APIResponseValidationError( + response=httpx.Response(200, request=request), + body={"error": str(e), "text": text}, + message=error_msg, + ) from e def get_input_tool_by_name(*, input_tools: Iterable[ToolParam], name: str) -> FunctionToolParam | None: diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8e5f16df95..4de26f3420 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -61,3 +61,187 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien checking_client.responses.parse, exclude_params={"tools"}, ) + + +@pytest.mark.respx(base_url=base_url) +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_parse_content_filter_error( + sync: bool, client: OpenAI, async_client: AsyncOpenAI, respx_mock: MockRouter +) -> None: + """Test that content moderation responses raise ContentFilterFinishReasonError.""" + from pydantic import BaseModel + from openai._exceptions import ContentFilterFinishReasonError + + class TestSchema(BaseModel): + name: str + value: int + + # Mock response with content filter and plain text refusal + response_data = { + "id": "resp_test123", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "background": False, + "error": None, + "incomplete_details": {"reason": "content_filter"}, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4.1", + "output": [ + { + "id": "msg_test123", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "I'm sorry, but I cannot assist you with that request.", + } + ], + "role": "assistant", + } + ], + "parallel_tool_calls": True, + "previous_response_id": None, + "prompt_cache_key": None, + "reasoning": {"effort": None, "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": True, + "temperature": 1.0, + "text": { + "format": {"type": "json_schema", "strict": True, "name": "TestSchema", "schema": {}}, + "verbosity": "medium", + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 10, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 20, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 30, + }, + "user": None, + "metadata": {}, + } + + import json + + respx_mock.post("/responses").mock(return_value=MockRouter.Response(200, json=response_data)) + + with pytest.raises(ContentFilterFinishReasonError) as exc_info: + if sync: + client.responses.parse( + model="gpt-4.1", + input="problematic content", + text_format=TestSchema, + ) + else: + await async_client.responses.parse( + model="gpt-4.1", + input="problematic content", + text_format=TestSchema, + ) + + assert "content filter" in str(exc_info.value).lower() + + +@pytest.mark.respx(base_url=base_url) +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_parse_validation_error( + sync: bool, client: OpenAI, async_client: AsyncOpenAI, respx_mock: MockRouter +) -> None: + """Test that invalid JSON responses raise APIResponseValidationError.""" + from pydantic import BaseModel + from openai._exceptions import APIResponseValidationError + + class TestSchema(BaseModel): + name: str + value: int + + # Mock response with invalid JSON (but no content filter) + response_data = { + "id": "resp_test456", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "background": False, + "error": None, + "incomplete_details": None, # No content filter + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4.1", + "output": [ + { + "id": "msg_test456", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "This is plain text, not JSON", + } + ], + "role": "assistant", + } + ], + "parallel_tool_calls": True, + "previous_response_id": None, + "prompt_cache_key": None, + "reasoning": {"effort": None, "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": True, + "temperature": 1.0, + "text": { + "format": {"type": "json_schema", "strict": True, "name": "TestSchema", "schema": {}}, + "verbosity": "medium", + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 10, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 20, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 30, + }, + "user": None, + "metadata": {}, + } + + import json + + respx_mock.post("/responses").mock(return_value=MockRouter.Response(200, json=response_data)) + + with pytest.raises(APIResponseValidationError) as exc_info: + if sync: + client.responses.parse( + model="gpt-4.1", + input="test input", + text_format=TestSchema, + ) + else: + await async_client.responses.parse( + model="gpt-4.1", + input="test input", + text_format=TestSchema, + ) + + error_msg = str(exc_info.value) + assert "TestSchema" in error_msg + assert "This is plain text, not JSON" in error_msg