From 9231cf64f23abce04d0a1e834370c4429630f69d Mon Sep 17 00:00:00 2001 From: Claudio Gallardo Date: Wed, 5 Nov 2025 00:14:31 -0300 Subject: [PATCH] feat: Add completion attribute to ContentFilterFinishReasonError Add 'completion' attribute to ContentFilterFinishReasonError for parity with LengthFinishReasonError, allowing developers to access usage info and response metadata when content filter is triggered. Changes: - Modified ContentFilterFinishReasonError class to accept completion parameter - Updated _parsing/_completions.py to pass completion object - Updated streaming/_completions.py to pass completion_snapshot - Added comprehensive tests verifying completion access and backward compatibility Fixes #2690 --- src/openai/_exceptions.py | 18 ++++- src/openai/lib/_parsing/_completions.py | 2 +- src/openai/lib/streaming/chat/_completions.py | 2 +- tests/lib/chat/test_completions.py | 75 +++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 09016dfedb..d8a044f13b 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -151,10 +151,20 @@ def __init__(self, *, completion: ChatCompletion) -> None: class ContentFilterFinishReasonError(OpenAIError): - def __init__(self) -> None: - super().__init__( - f"Could not parse response content as the request was rejected by the content filter", - ) + completion: ChatCompletion + """The completion that caused this error. + + Note: this will *not* be a complete `ChatCompletion` object when streaming as `usage` + will not be included. + """ + + def __init__(self, *, completion: ChatCompletion) -> None: + msg = "Could not parse response content as the request was rejected by the content filter" + if completion.usage: + msg += f" - {completion.usage}" + + super().__init__(msg) + self.completion = completion class InvalidWebhookSignatureError(ValueError): diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 7903732a4a..cc58a87c96 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -100,7 +100,7 @@ def parse_chat_completion( raise LengthFinishReasonError(completion=chat_completion) if choice.finish_reason == "content_filter": - raise ContentFilterFinishReasonError() + raise ContentFilterFinishReasonError(completion=chat_completion) message = choice.message diff --git a/src/openai/lib/streaming/chat/_completions.py b/src/openai/lib/streaming/chat/_completions.py index c4610e2120..da9b77b8ad 100644 --- a/src/openai/lib/streaming/chat/_completions.py +++ b/src/openai/lib/streaming/chat/_completions.py @@ -432,7 +432,7 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS raise LengthFinishReasonError(completion=completion_snapshot) if choice.finish_reason == "content_filter": - raise ContentFilterFinishReasonError() + raise ContentFilterFinishReasonError(completion=completion_snapshot) if ( choice_snapshot.message.content diff --git a/tests/lib/chat/test_completions.py b/tests/lib/chat/test_completions.py index afad5a1391..31de110d49 100644 --- a/tests/lib/chat/test_completions.py +++ b/tests/lib/chat/test_completions.py @@ -993,3 +993,78 @@ def test_parse_method_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpe checking_client.chat.completions.parse, exclude_params={"response_format", "stream"}, ) + + +@pytest.mark.respx(base_url=base_url) +def test_parse_content_filter_includes_completion(client: OpenAI, respx_mock: MockRouter) -> None: + """Verify ContentFilterFinishReasonError exposes completion object with usage info.""" + class Location(BaseModel): + city: str + temperature: float + + with pytest.raises(openai.ContentFilterFinishReasonError) as exc_info: + make_snapshot_request( + lambda c: c.chat.completions.parse( + model="gpt-4o-2024-08-06", + messages=[ + { + "role": "user", + "content": "Test content that triggers filter", + }, + ], + response_format=Location, + ), + content_snapshot=snapshot( + '{"id": "chatcmpl-test123", "object": "chat.completion", "created": 1727346163, "model": "gpt-4o-2024-08-06", "choices": [{"index": 0, "message": {"role": "assistant", "content": null, "refusal": null}, "logprobs": null, "finish_reason": "content_filter"}], "usage": {"prompt_tokens": 50, "completion_tokens": 0, "total_tokens": 50, "completion_tokens_details": {"reasoning_tokens": 0}}, "system_fingerprint": "fp_test"}' + ), + path="/chat/completions", + mock_client=client, + respx_mock=respx_mock, + ) + + error = exc_info.value + + # Verify completion is accessible + assert error.completion is not None + assert error.completion.id == "chatcmpl-test123" + assert error.completion.model == "gpt-4o-2024-08-06" + + # Verify usage information is accessible + assert error.completion.usage is not None + assert error.completion.usage.total_tokens == 50 + assert error.completion.usage.prompt_tokens == 50 + assert error.completion.usage.completion_tokens == 0 + + # Verify usage is included in error message + error_msg = str(error) + assert "content filter" in error_msg.lower() + assert "prompt_tokens=50" in error_msg + assert "total_tokens=50" in error_msg + + +@pytest.mark.respx(base_url=base_url) +def test_parse_content_filter_backward_compatibility(client: OpenAI, respx_mock: MockRouter) -> None: + """Verify existing try/except blocks still work after adding completion parameter.""" + class Location(BaseModel): + city: str + + # Old code that just catches the error should still work + caught_error = False + try: + make_snapshot_request( + lambda c: c.chat.completions.parse( + model="gpt-4o-2024-08-06", + messages=[{"role": "user", "content": "Test"}], + response_format=Location, + ), + content_snapshot=snapshot( + '{"id": "chatcmpl-test456", "object": "chat.completion", "created": 1727346163, "model": "gpt-4o-2024-08-06", "choices": [{"index": 0, "message": {"role": "assistant", "content": null}, "finish_reason": "content_filter"}], "usage": {"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10}}' + ), + path="/chat/completions", + mock_client=client, + respx_mock=respx_mock, + ) + except openai.ContentFilterFinishReasonError: + caught_error = True + + assert caught_error, "Exception should still be catchable"