fix(anthropic): also enforce leading-user + empty-array recovery
Extend `_merge_consecutive` so the three invariants from `LLMProvider._enforce_role_alternation` all hold for Anthropic: 1. collapse consecutive same-role turns (unchanged) 2. no trailing assistant — Anthropic rejects prefill (unchanged) 3. no leading assistant — Anthropic requires the first turn be user 4. non-empty messages array — recover the last stripped assistant as a user turn when every turn got stripped, so callers don't hit a secondary "messages array empty" 400 Anthropic-specific wrinkle: `tool_use` blocks live inside `content` (not a separate `tool_calls` field) and are illegal inside user turns, so both recovery paths skip any message carrying them rather than silently producing a malformed request. Adds 4 unit tests covering the new branches, including the tool_use opt-outs, and updates the existing `test_single_assistant_stripped` to reflect the new rerouting contract. Made-with: Cursor
This commit is contained in:
@@ -246,12 +246,39 @@ class AnthropicProvider(LLMProvider):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _has_tool_use(msg: dict[str, Any]) -> bool:
|
||||||
"""Anthropic requires alternating user/assistant roles.
|
"""True if ``msg.content`` carries any ``tool_use`` block.
|
||||||
|
|
||||||
Also strips trailing assistant messages since Anthropic does not
|
Anthropic forbids ``tool_use`` inside ``user`` turns, so messages that
|
||||||
support assistant-message prefill and will reject the request with
|
issued a tool call cannot be safely rerouted when we patch the role.
|
||||||
a 400 error if the conversation ends with an assistant turn.
|
"""
|
||||||
|
content = msg.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
isinstance(block, dict) and block.get("type") == "tool_use"
|
||||||
|
for block in content
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Normalize a message sequence for Anthropic's ``/messages`` endpoint.
|
||||||
|
|
||||||
|
Anthropic's contract is stricter than OpenAI's:
|
||||||
|
|
||||||
|
1. Consecutive same-role turns must be collapsed into one.
|
||||||
|
2. The conversation cannot end with an ``assistant`` turn — Anthropic
|
||||||
|
does not support assistant-message prefill and returns 400.
|
||||||
|
3. The conversation cannot start with an ``assistant`` turn — the
|
||||||
|
first message must be ``user``.
|
||||||
|
|
||||||
|
Rules 2 and 3 mirror ``LLMProvider._enforce_role_alternation`` in
|
||||||
|
``base.py``, which applies the equivalent invariants to OpenAI-compat
|
||||||
|
providers. The only Anthropic-specific wrinkle: ``tool_use`` blocks
|
||||||
|
live inside ``content`` (not a separate ``tool_calls`` field) and are
|
||||||
|
invalid inside ``user`` turns, so the recovery paths below must skip
|
||||||
|
any message carrying them rather than silently producing a malformed
|
||||||
|
request.
|
||||||
"""
|
"""
|
||||||
merged: list[dict[str, Any]] = []
|
merged: list[dict[str, Any]] = []
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
@@ -268,10 +295,34 @@ class AnthropicProvider(LLMProvider):
|
|||||||
else:
|
else:
|
||||||
merged.append(msg)
|
merged.append(msg)
|
||||||
|
|
||||||
# Drop trailing assistant messages to avoid Anthropic's
|
# Rule 2: strip trailing assistant turns — Anthropic rejects prefill.
|
||||||
# "does not support assistant message prefill" 400 error.
|
last_popped: dict[str, Any] | None = None
|
||||||
while merged and merged[-1].get("role") == "assistant":
|
while merged and merged[-1].get("role") == "assistant":
|
||||||
merged.pop()
|
last_popped = merged.pop()
|
||||||
|
|
||||||
|
# Recovery for rule 2: if stripping removed every turn, reroute the
|
||||||
|
# last popped assistant as a user turn so upstream code still gets a
|
||||||
|
# valid request instead of a secondary "messages array empty" 400.
|
||||||
|
# Skip when the message carried ``tool_use`` blocks (see _has_tool_use).
|
||||||
|
if (
|
||||||
|
not merged
|
||||||
|
and last_popped is not None
|
||||||
|
and not AnthropicProvider._has_tool_use(last_popped)
|
||||||
|
):
|
||||||
|
merged.append({"role": "user", "content": last_popped.get("content")})
|
||||||
|
|
||||||
|
# Rule 3: prepend a synthetic opener if the first surviving turn is an
|
||||||
|
# assistant (e.g. upstream history truncation dropped the original
|
||||||
|
# user request). ``tool_use``-carrying assistants are left alone —
|
||||||
|
# that message will still fail validation, but injecting an opener
|
||||||
|
# before it would orphan the tool_use/tool_result pair that follows,
|
||||||
|
# turning a recoverable 400 into a harder-to-diagnose one.
|
||||||
|
if (
|
||||||
|
merged
|
||||||
|
and merged[0].get("role") == "assistant"
|
||||||
|
and not AnthropicProvider._has_tool_use(merged[0])
|
||||||
|
):
|
||||||
|
merged.insert(0, {"role": "user", "content": "(conversation continued)"})
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,80 @@ class TestMergeConsecutive:
|
|||||||
result = AnthropicProvider._merge_consecutive(msgs)
|
result = AnthropicProvider._merge_consecutive(msgs)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
def test_single_assistant_stripped(self):
|
def test_single_assistant_rerouted_to_user(self):
|
||||||
|
"""When stripping leaves nothing, the last assistant is rerouted to
|
||||||
|
``user`` so we don't produce an empty messages array."""
|
||||||
msgs = [{"role": "assistant", "content": "hi"}]
|
msgs = [{"role": "assistant", "content": "hi"}]
|
||||||
result = AnthropicProvider._merge_consecutive(msgs)
|
result = AnthropicProvider._merge_consecutive(msgs)
|
||||||
assert len(result) == 0
|
assert len(result) == 1
|
||||||
|
assert result[0]["role"] == "user"
|
||||||
|
assert result[0]["content"] == "hi"
|
||||||
|
|
||||||
|
def test_all_assistants_collapse_then_rerouted(self):
|
||||||
|
"""Consecutive trailing assistants merge into one, which is then
|
||||||
|
rerouted as a user turn carrying the merged content."""
|
||||||
|
msgs = [
|
||||||
|
{"role": "assistant", "content": "a"},
|
||||||
|
{"role": "assistant", "content": "b"},
|
||||||
|
]
|
||||||
|
result = AnthropicProvider._merge_consecutive(msgs)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["role"] == "user"
|
||||||
|
# "b" was merged into "a"'s block list during the merge pass.
|
||||||
|
assert result[0]["content"] == [
|
||||||
|
{"type": "text", "text": "a"},
|
||||||
|
{"type": "text", "text": "b"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_assistant_with_tool_use_not_rerouted(self):
|
||||||
|
"""A trailing assistant carrying ``tool_use`` blocks cannot become a
|
||||||
|
user turn (Anthropic rejects ``tool_use`` inside user messages), so
|
||||||
|
the method returns an empty list rather than forging a bad request."""
|
||||||
|
msgs = [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "let me search"},
|
||||||
|
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = AnthropicProvider._merge_consecutive(msgs)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_leading_assistant_gets_synthetic_user(self):
|
||||||
|
"""If the first turn is a bare assistant (e.g. history truncation
|
||||||
|
dropped the original user request), prepend a synthetic opener so
|
||||||
|
the conversation still starts with ``user``."""
|
||||||
|
msgs = [
|
||||||
|
{"role": "assistant", "content": "hi"},
|
||||||
|
{"role": "user", "content": "ok"},
|
||||||
|
{"role": "assistant", "content": "reply"},
|
||||||
|
]
|
||||||
|
result = AnthropicProvider._merge_consecutive(msgs)
|
||||||
|
assert [m["role"] for m in result] == ["user", "assistant", "user"]
|
||||||
|
assert result[0]["content"] == "(conversation continued)"
|
||||||
|
assert result[1]["content"] == "hi"
|
||||||
|
assert result[2]["content"] == "ok"
|
||||||
|
|
||||||
|
def test_leading_assistant_with_tool_use_left_alone(self):
|
||||||
|
"""Don't prepend a synthetic opener before an assistant carrying
|
||||||
|
``tool_use``; doing so would orphan the paired ``tool_result`` that
|
||||||
|
follows. The caller will see the original 400 rather than a
|
||||||
|
harder-to-diagnose tool-pair mismatch."""
|
||||||
|
msgs = [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
result = AnthropicProvider._merge_consecutive(msgs)
|
||||||
|
assert [m["role"] for m in result] == ["assistant", "user"]
|
||||||
|
|||||||
Reference in New Issue
Block a user