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..bc93510e 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -884,17 +884,22 @@ 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) 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 = [ + 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 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