fix(anthropic): strip trailing assistant messages to prevent prefill error

Anthropic does not support assistant-message prefill and returns a 400
error when the conversation ends with an assistant turn. This commonly
happens when heartbeat/system messages accumulate trailing assistant
replies in the session history.

The _merge_consecutive method already handles same-role merging but did
not strip trailing assistant messages. The base provider's
_enforce_role_alternation (used by OpenAI-compat) does strip them, but
AnthropicProvider uses its own _merge_consecutive instead.

Add a trailing-assistant stripping loop to _merge_consecutive, matching
the behavior already present in _enforce_role_alternation.

Includes 7 new tests covering merge + strip behavior.
This commit is contained in:
hussein1362
2026-04-21 01:32:32 +08:00
committed by Xubin Ren
parent 00de55072d
commit 2f02342083
2 changed files with 78 additions and 1 deletions
+12 -1
View File
@@ -247,7 +247,12 @@ class AnthropicProvider(LLMProvider):
@staticmethod
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Anthropic requires alternating user/assistant roles."""
"""Anthropic requires alternating user/assistant roles.
Also strips trailing assistant messages since Anthropic does not
support assistant-message prefill and will reject the request with
a 400 error if the conversation ends with an assistant turn.
"""
merged: list[dict[str, Any]] = []
for msg in msgs:
if merged and merged[-1]["role"] == msg["role"]:
@@ -262,6 +267,12 @@ class AnthropicProvider(LLMProvider):
merged[-1]["content"] = prev_c
else:
merged.append(msg)
# Drop trailing assistant messages to avoid Anthropic's
# "does not support assistant message prefill" 400 error.
while merged and merged[-1].get("role") == "assistant":
merged.pop()
return merged
# ------------------------------------------------------------------
@@ -0,0 +1,66 @@
"""Tests for AnthropicProvider._merge_consecutive."""
from nanobot.providers.anthropic_provider import AnthropicProvider
class TestMergeConsecutive:
"""Verify role alternation and trailing-assistant stripping."""
def test_basic_alternation(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "bye"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 3
assert [m["role"] for m in result] == ["user", "assistant", "user"]
def test_consecutive_same_role_merged(self):
msgs = [
{"role": "user", "content": "a"},
{"role": "user", "content": "b"},
{"role": "assistant", "content": "reply"},
]
result = AnthropicProvider._merge_consecutive(msgs)
# Two user messages merged into one, trailing assistant stripped
assert len(result) == 1
assert result[0]["role"] == "user"
def test_trailing_assistant_stripped(self):
"""Anthropic rejects prefill — trailing assistant must be removed."""
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
assert result[0]["content"] == "hello"
def test_multiple_trailing_assistant_stripped(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "a"},
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "b"},
{"role": "assistant", "content": "c"},
]
result = AnthropicProvider._merge_consecutive(msgs)
# b+c merged into one assistant, then stripped as trailing
assert len(result) == 3
assert result[-1]["role"] == "user"
assert result[-1]["content"] == "ok"
def test_empty_messages(self):
assert AnthropicProvider._merge_consecutive([]) == []
def test_single_user_message(self):
msgs = [{"role": "user", "content": "hi"}]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
def test_single_assistant_stripped(self):
msgs = [{"role": "assistant", "content": "hi"}]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 0