Skip to content

Commit 59983d6

Browse files
gadievronclaude
andauthored
fix(google): raise on an empty candidate instead of a clean end_turn (#223)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d01c021 commit 59983d6

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

libs/openant-core/tests/test_llm_google_adapter.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,32 @@ def test_rate_limit_reports_to_global_limiter():
8484
max_tokens=8,
8585
)
8686
assert limiter.is_in_backoff(), "Google 429 must trigger global backoff (H1)"
87+
88+
89+
def test_present_candidate_with_empty_parts_raises_not_clean_end_turn():
90+
# A candidate present with a clean STOP finish but NO usable parts (thinking-only
91+
# or blank) must raise -- returning an empty end_turn reads as a clean, passing
92+
# result for a security tool (silent false-negative). Mirrors the no-candidates
93+
# guard and the Anthropic/OpenAI empty-content guards.
94+
from types import SimpleNamespace
95+
from utilities.llm import LLMResponseError
96+
from utilities.llm.providers.google import _response_to_unified
97+
cand = SimpleNamespace(finish_reason="STOP", content=SimpleNamespace(parts=[]))
98+
resp = SimpleNamespace(candidates=[cand], usage_metadata=SimpleNamespace(
99+
prompt_token_count=1, candidates_token_count=0, total_token_count=1))
100+
with pytest.raises(LLMResponseError):
101+
_response_to_unified(resp)
102+
103+
104+
def test_tool_use_only_candidate_is_valid_not_empty():
105+
# Control: a function_call part with no text is a VALID response (content
106+
# non-empty) and must NOT be caught by the empty-content guard.
107+
from types import SimpleNamespace
108+
from utilities.llm.providers.google import _response_to_unified
109+
fc = SimpleNamespace(name="do_it", args={"x": 1}, id="g1")
110+
part = SimpleNamespace(text=None, function_call=fc)
111+
cand = SimpleNamespace(finish_reason="STOP", content=SimpleNamespace(parts=[part]))
112+
resp = SimpleNamespace(candidates=[cand], usage_metadata=SimpleNamespace(
113+
prompt_token_count=1, candidates_token_count=1, total_token_count=2))
114+
result = _response_to_unified(resp)
115+
assert result.content and result.stop_reason == "tool_use"

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,23 @@ def _response_to_unified(response: Any) -> CompletionResult:
439439
"the candidate was withheld for safety or policy reasons"
440440
)
441441

442+
# R4-1: a candidate that carried NO usable content -- no TextBlock and no
443+
# function_call ToolUseBlock (a thinking-only/blank candidate, or one whose
444+
# parts were all dropped) -- has nothing the pipeline can act on. Surface it
445+
# via the taxonomy instead of returning an empty end_turn, which pipeline
446+
# code would read as a clean (passing) result -- for a security tool that
447+
# masks a blank as a non-finding. Mirrors the no-candidates guard above and
448+
# the Anthropic / OpenAI empty-content guards. A tool-use-only candidate is
449+
# VALID and not caught here because content_blocks is non-empty. Refusal is
450+
# the more specific signal and already raised above.
451+
if not content_blocks:
452+
raise LLMResponseError(
453+
"Gemini returned a candidate with no usable content (empty "
454+
"completion); the response may have been truncated (a thinking "
455+
"model consumed the token budget before emitting output) or "
456+
"filtered/malformed"
457+
)
458+
442459
stop_reason: StopReason
443460
has_tool_use = any(isinstance(b, ToolUseBlock) for b in content_blocks)
444461
mapped = _GEMINI_FINISH_REASONS.get(raw_finish)

0 commit comments

Comments
 (0)