From 068df807640172f80166121235195f72587c385c Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 3 Sep 2026 19:42:20 -0500 Subject: [PATCH 1/2] fix(anthropic): send placeholder for empty assistant turns instead of dropping Dropping an empty assistant turn could produce two consecutive user messages, violating the API's user/assistant alternation requirement. Send an '[empty string]' placeholder instead, matching ellmer's fix (tidyverse/ellmer#1099) and chatlas's existing empty-text normalization. Closes #416 --- CHANGELOG.md | 1 + chatlas/_provider_anthropic.py | 9 ++++++--- tests/test_provider_anthropic.py | 18 ++++++++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd03e14..0730e67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ChatBedrock()` now defaults `base_url` to the official AWS SDKs' endpoint override environment variables when set: `AWS_ENDPOINT_URL_BEDROCK_RUNTIME` for `api="converse"`, and `AWS_ENDPOINT_URL_BEDROCK_MANTLE` for `api="messages"` and `api="responses"`. Similarly, `ChatAnthropic()` respects the `ANTHROPIC_BASE_URL` environment variable (via the anthropic SDK). Setting these variables is enough to route requests through a proxy or gateway, so you don't have to pass `base_url` on every call. ### Bug fixes +* `ChatAnthropic()` no longer drops assistant turns that have no content; doing so could produce two consecutive user messages, violating the API's user/assistant alternation requirement. A `"[empty string]"` placeholder is sent instead, matching how empty text content is already normalized. (#416) * `ChatDatabricks()` no longer drops the assistant's reply from the conversation when a GPT-OSS endpoint streams typed content. The typed part array was merged into the accumulated completion before it was normalized, so every later text delta was appended to it one character at a time and the finished turn came back empty. (#409) * `.to_solver()` no longer corrupts the system prompt or the prior turns it reads out of Inspect AI's message state. The system prompt was being set to the `repr()` of the `ChatMessageSystem` object rather than its text, and message content arriving in Inspect AI's `str` form (rather than as a list of `Content`) was iterated one character at a time. (#407) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 5e41db65..f6adb4ab 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -891,10 +891,13 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]: or anthropic_replayable(c) ] - # Drop empty assistant turns to avoid an API error - # (all messages must have non-empty content) + # An assistant turn with no content can't simply be dropped: + # doing so could produce two consecutive user messages, + # violating the API's user/assistant alternation requirement. + # Send a placeholder instead (the API also requires all + # messages to have non-empty content). if turn.role == "assistant" and len(content) == 0: - continue + content = [{"type": "text", "text": "[empty string]"}] # Add cache control to the last content block in the last turn # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#how-automatic-prefix-checking-works diff --git a/tests/test_provider_anthropic.py b/tests/test_provider_anthropic.py index d878b2fa..677f20dd 100644 --- a/tests/test_provider_anthropic.py +++ b/tests/test_provider_anthropic.py @@ -928,13 +928,18 @@ def test_anthropic_list_models(): assert_list_models(chat_func) -def test_anthropic_removes_empty_assistant_turns(): - """Test that empty assistant turns are dropped to avoid API errors.""" +def test_anthropic_empty_assistant_turn_placeholder(): + """Empty assistant turns get a placeholder instead of being dropped (#416). + + Dropping the turn could produce two consecutive user messages, violating + the API's user/assistant alternation requirement. + """ chat = chat_func() chat.set_turns( [ UserTurn("Don't say anything"), AssistantTurn([]), + UserTurn("What did I just say?"), ] ) @@ -942,10 +947,11 @@ def test_anthropic_removes_empty_assistant_turns(): provider = cast(AnthropicProvider, chat.provider) turns_json = provider._as_message_params(chat.get_turns()) - # Should only have the user turn, not the empty assistant turn - assert len(turns_json) == 1 - assert turns_json[0]["role"] == "user" - assert turns_json[0]["content"][0]["text"] == "Don't say anything" # type: ignore + # The empty assistant turn is kept (with placeholder content), so + # user/assistant roles still alternate as the API requires + assert [m["role"] for m in turns_json] == ["user", "assistant", "user"] + assert turns_json[1]["content"] == [{"type": "text", "text": "[empty string]"}] + assert turns_json[2]["content"][0]["text"] == "What did I just say?" # type: ignore @pytest.mark.vcr From a7d65f4219eb223f0eed22a26d90b300d9e137fe Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 4 Sep 2026 09:14:16 -0500 Subject: [PATCH 2/2] fix type error: annotate content as list[ContentBlockParam] --- chatlas/_provider_anthropic.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index f6adb4ab..bc93510e 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -884,7 +884,7 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]: if not isinstance(turn, (UserTurn, AssistantTurn)): raise ValueError(f"Unknown role {turn.role}") - content = [ + content: list[ContentBlockParam] = [ self._as_content_block(c) for c in turn.contents if not isinstance(c, PROVIDER_ANNOTATION_TYPES) @@ -897,7 +897,9 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]: # Send a placeholder instead (the API also requires all # messages to have non-empty content). if turn.role == "assistant" and len(content) == 0: - content = [{"type": "text", "text": "[empty string]"}] + content = [ + cast("TextBlockParam", {"type": "text", "text": "[empty string]"}) + ] # Add cache control to the last content block in the last turn # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#how-automatic-prefix-checking-works