From f9fa455aef57cca85593dd1e9aef7e9a2155b8a0 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 7 Feb 2026 17:14:09 -0800 Subject: [PATCH] fix: handle content moderation in responses.parse() Fixed responses.parse() to properly handle content moderation responses. When the API returns a plain-text refusal due to content filtering (e.g., "I'm sorry, but I cannot assist you with that request."), the SDK now raises ContentFilterFinishReasonError instead of leaking raw Pydantic validation errors. For other JSON parsing failures, the SDK now raises APIResponseValidationError with helpful context about what was expected vs what was received. Changes: - Modified parse_text() to accept response object and check for content_filter - Added try-except around JSON parsing with proper error handling - Added tests for both content filter and general validation error cases Fixes #2834 --- src/openai/lib/_parsing/_responses.py | 57 ++++++-- tests/lib/responses/test_responses.py | 184 ++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 12 deletions(-) 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