Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
)
)
Expand Down Expand Up @@ -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:
Expand Down
184 changes: 184 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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