fix(models): an empty Anthropic end_turn is a silent turn, not an API error - #48
fix(models): an empty Anthropic end_turn is a silent turn, not an API error#48rezaho wants to merge 1 commit into
Conversation
… error A model that runs to natural completion and produces no content blocks is returning a success: "I have nothing to say." Both Anthropic adapters raised a typed ModelAPIError on it instead, and the classifier pinned that error non-retryable — so every silent turn died terminally. This is a behaviour callers explicitly ask for. An agent instructed to stay quiet when it has nothing to report (a heartbeat that found no work, an inbox check that found no mail) obeys, ends the turn with zero content blocks and stop_reason 'end_turn', and the provider bills it as a successful response. Confirmed on the wire: message_start (content []) -> message_delta (stop_reason end_turn, output_tokens 2) -> message_stop, no content_block_start. The raise was a symptom. The root is that HarmonizedResponse could not represent the outcome: its validator rejects content=None with no tool_calls, the adapters normalize empty text to None, and the empty-output branch had only two arms (deterministic truncation -> placeholder; everything else -> raise). The typed error was added to replace an UNKNOWN ValidationError, which improved the error's quality while cementing a success as an error class. The validator already blesses the shape this needs: it checks `content is None` specifically, because an empty string is a valid response from some providers. The API-key adapter already uses that escape for thinking-only responses. So the empty-output branch gains its correct third arm — end_turn harmonizes to content="" — beside the two that exist, rather than a parallel path. Refusal and a stream that closed with no terminal at all keep raising: those are genuine anomalies, and that distinction is the value the typed-error work added. The test asserting empty end_turn raises is INVERTED, not removed: it encoded the wrong contract. Its replacement pins the silent turn, and a twin covers the API-key adapter so one provider keeps one behaviour. The now-unreachable end_turn classification arm is deleted.
There was a problem hiding this comment.
Code Review
This pull request updates the Anthropic adapters to treat empty responses with an 'end_turn' stop reason as successful 'silent turns' by harmonizing them to an empty string instead of raising a ModelAPIError. The reviewer pointed out a latent gap in the OAuth adapter where thinking-only responses would still trigger validation failures because they bypass the empty-output block, and provided a code suggestion to resolve this inconsistency.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| content = text_content if text_content else None | ||
| if not text_content and not tool_calls and not raw_response.get("thinking"): | ||
| if finish_reason == "length": | ||
| text_content = ( | ||
| content = ( | ||
| "[Response truncated due to token limit. Please increase max_tokens " | ||
| "or continue the conversation.]" | ||
| ) | ||
| elif stop_reason_raw == "end_turn": | ||
| # A silent turn: the model ran to natural completion and produced | ||
| # nothing. Callers ask for this (an agent told to stay quiet when | ||
| # it has nothing to report), the provider bills it as a success, | ||
| # and the empty STRING is the content shape that carries it — the | ||
| # validator's rejection is of None, never of "". The API-key twin | ||
| # uses the same escape for its thinking-only responses. | ||
| content = "" | ||
| else: |
There was a problem hiding this comment.
By checking not raw_response.get("thinking") in the outer if condition, a thinking-only response (which has no text content and no tool calls) will skip this block entirely. This leaves content as None, which subsequently triggers a validation failure in HarmonizedResponse since it requires either content or tool calls. We can close this latent gap and align the OAuth adapter's behavior with the standard Anthropic adapter by allowing the block to execute when there is no text content and no tool calls, and then setting content = "" if it is either an end_turn or a thinking-only response.
content = text_content if text_content else None
if not text_content and not tool_calls:
if finish_reason == "length":
content = (
"[Response truncated due to token limit. Please increase max_tokens "
"or continue the conversation.]"
)
elif stop_reason_raw == "end_turn" or raw_response.get("thinking"):
# A silent turn or thinking-only response: the model ran to natural
# completion and produced nothing, or only thinking. The empty STRING
# is the content shape that carries it to avoid validation failure.
content = ""
else:|
Superseded by #49, which is merged. This PR's commit |
The bug
Both Anthropic adapters raised a typed
ModelAPIErrorwhen the model finished normally (stop_reason: end_turn) with no content blocks, and the classifier pinned that error non-retryable. Every such turn died terminally.But that response is a success. A model instructed to stay quiet when it has nothing to report — a heartbeat that found no work, an inbox check that found no mail — obeys by ending the turn with zero content blocks. The provider bills it as a successful, well-formed response meaning "I have nothing to say."
Confirmed on the wire against the live API:
No
content_block_startat all. Nothing was dropped by the stream reader — nothing was sent.Downstream this terminally killed a real agent's recurring task on every firing, and it was self-reinforcing: the dead turn persisted nothing, so each replay showed the model another in-context example of staying silent.
The root
The raise was a symptom.
HarmonizedResponsecannot represent the outcome — its validator rejectscontent=Nonewith no tool_calls, the adapters normalize empty text toNone, and the empty-output branch had only two arms (deterministic truncation → placeholder; everything else → raise). The typed error was originally added to replace an opaqueUNKNOWNValidationError, which improved the error's quality while cementing a success as an error class.The fix
The validator already blesses the shape this needs — it checks
content is Nonespecifically, with a comment noting that an empty string is a valid response from some providers. The API-key adapter already uses that exact escape for thinking-only responses.So the empty-output branch gains its correct third arm, beside the two that already exist rather than as a parallel path:
max_tokens/model_context_window_exceededend_turncontent=""— a silent turn (new)refusal, no terminal at allModelAPIError(unchanged)Refusal and a stream that closed without any terminal keep raising — those are genuine anomalies, and preserving that distinction is the value the typed-error work added. The now-unreachable
end_turnclassification arm inexceptions.pyis deleted.Both adapters get the arm, so one provider keeps one behaviour.
Tests
The test asserting that an empty
end_turnraises is inverted, not removed — it encoded the wrong contract, and deleting it quietly would hide that this PR reverses a recorded decision. Its replacement pins the silent turn, and a twin covers the API-key adapter.Framework suite: green (1362 passed; the 3 unrelated
coordination/failures and thereadlinecollection errors are pre-existing on Windows and fail identically without this change).