Skip to content

Commit df56905

Browse files
gadievronclaude
andcommitted
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 <noreply@anthropic.com>
1 parent fad4b01 commit df56905

2 files changed

Lines changed: 31 additions & 0 deletions

File tree

libs/openant-core/tests/test_llm_openai_adapter.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,23 @@ def test_empty_choices_raises_llm_response_error():
172172
adapter.complete(model="gpt-4o", system=None, messages=_hi(), max_tokens=8)
173173

174174

175+
def test_empty_content_raises_llm_response_error():
176+
# A choice with neither text nor tool calls is an empty completion: it must
177+
# surface via the taxonomy, not read as a clean end_turn. Parity with the
178+
# Responses path's no-usable-content guard and the Anthropic/Gemini adapters
179+
# -- for a security tool an empty end_turn would read as a clean pass.
180+
empty = SimpleNamespace(
181+
choices=[SimpleNamespace(
182+
message=SimpleNamespace(content=None, tool_calls=None),
183+
finish_reason="stop",
184+
)],
185+
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=0),
186+
)
187+
adapter, _ = _stub(lambda **kw: empty)
188+
with pytest.raises(LLMResponseError):
189+
adapter.complete(model="gpt-4o", system=None, messages=_hi(), max_tokens=8)
190+
191+
175192
# ---------------------------------------------------------------------------
176193
# L3 — pricing table carries current models so they don't report $0
177194
# ---------------------------------------------------------------------------

libs/openant-core/utilities/llm/providers/openai.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,20 @@ def _response_to_unified(response: Any) -> CompletionResult:
797797
"or truncated by the moderation layer"
798798
)
799799

800+
# An empty completion -- no text AND no tool calls (``message.content`` is
801+
# None/empty with no ``tool_calls``) -- carries nothing the pipeline can act
802+
# on. Surface it via the taxonomy instead of returning an empty end_turn
803+
# (mirrors the Responses path's no-usable-content guard and the Anthropic/
804+
# Gemini adapters); for a SECURITY tool an empty end_turn would read as a
805+
# clean, passing result. A tool-use-only response is VALID and not caught
806+
# here because ``content_blocks`` is non-empty. Refusal/content_filter is the
807+
# more specific signal and already raised above.
808+
if not content_blocks:
809+
raise LLMResponseError(
810+
"OpenAI returned an empty completion (no text or tool calls); the "
811+
"request may have been filtered or the response was malformed"
812+
)
813+
800814
if raw_finish not in _OPENAI_FINISH_REASONS:
801815
should_warn = False
802816
with _warned_finish_reasons_lock:

0 commit comments

Comments
 (0)