From df569050907bf79d83a95ee25d4c3c0e0b4995bc Mon Sep 17 00:00:00 2001 From: gadievron Date: Sun, 2 Aug 2026 13:40:56 +0300 Subject: [PATCH] fix(openai): raise on empty chat completion instead of clean end_turn The Chat Completions path guarded an empty `choices` array but not an empty message: a choice whose `message.content` is None/empty with no `tool_calls` returned a clean `end_turn` carrying no content. For a security tool an empty end_turn reads as a passing verdict, so a blank completion became a silent false-negative. Add the no-usable-content guard the three sibling paths already have -- the Responses path, the Anthropic adapter, and the Gemini adapter all raise `LLMResponseError` on empty content. A tool-use-only response stays valid (content_blocks is non-empty); refusal/content_filter still raises first as the more specific signal. Regression test: a choice with content=None and no tool_calls now raises `LLMResponseError` (was a clean end_turn). Co-Authored-By: Claude Opus 4.8 --- .../tests/test_llm_openai_adapter.py | 17 +++++++++++++++++ .../utilities/llm/providers/openai.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/libs/openant-core/tests/test_llm_openai_adapter.py b/libs/openant-core/tests/test_llm_openai_adapter.py index 3a3a65b..308d879 100644 --- a/libs/openant-core/tests/test_llm_openai_adapter.py +++ b/libs/openant-core/tests/test_llm_openai_adapter.py @@ -172,6 +172,23 @@ def test_empty_choices_raises_llm_response_error(): adapter.complete(model="gpt-4o", system=None, messages=_hi(), max_tokens=8) +def test_empty_content_raises_llm_response_error(): + # A choice with neither text nor tool calls is an empty completion: it must + # surface via the taxonomy, not read as a clean end_turn. Parity with the + # Responses path's no-usable-content guard and the Anthropic/Gemini adapters + # -- for a security tool an empty end_turn would read as a clean pass. + empty = SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace(content=None, tool_calls=None), + finish_reason="stop", + )], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=0), + ) + adapter, _ = _stub(lambda **kw: empty) + with pytest.raises(LLMResponseError): + adapter.complete(model="gpt-4o", system=None, messages=_hi(), max_tokens=8) + + # --------------------------------------------------------------------------- # L3 — pricing table carries current models so they don't report $0 # --------------------------------------------------------------------------- diff --git a/libs/openant-core/utilities/llm/providers/openai.py b/libs/openant-core/utilities/llm/providers/openai.py index d8ee06c..9207a3b 100644 --- a/libs/openant-core/utilities/llm/providers/openai.py +++ b/libs/openant-core/utilities/llm/providers/openai.py @@ -797,6 +797,20 @@ def _response_to_unified(response: Any) -> CompletionResult: "or truncated by the moderation layer" ) + # An empty completion -- no text AND no tool calls (``message.content`` is + # None/empty with no ``tool_calls``) -- carries nothing the pipeline can act + # on. Surface it via the taxonomy instead of returning an empty end_turn + # (mirrors the Responses path's no-usable-content guard and the Anthropic/ + # Gemini adapters); for a SECURITY tool an empty end_turn would read as a + # clean, passing result. A tool-use-only response is VALID and not caught + # here because ``content_blocks`` is non-empty. Refusal/content_filter is the + # more specific signal and already raised above. + if not content_blocks: + raise LLMResponseError( + "OpenAI returned an empty completion (no text or tool calls); the " + "request may have been filtered or the response was malformed" + ) + if raw_finish not in _OPENAI_FINISH_REASONS: should_warn = False with _warned_finish_reasons_lock: