fix(anthropic): keep mid-conversation system out of the chat completions system prompt

translate_system_message hoisted every role=system message, at any index, into
the top-level system block. On a conversation carrying a mid-session reminder
that rewrites the cached prefix, so the provider re-bills the whole history at
cache-write pricing on every turn (#36559). #36968 fixed this on /v1/messages;
the chat completions path, shared by first-party Anthropic, Vertex, Azure AI
and Bedrock Invoke, still hoisted.

Only the leading system run becomes the system prompt now. Later system
messages go through the placement policy, and anthropic_messages_pt emits a
system message instead of rejecting the role. The caller's message list is no
longer mutated. Tests pin the two-turn prefix invariant across all four chat
configs and both flag states.
This commit is contained in:
Shifat Islam Santo 2026-08-23 19:44:25 -05:00
parent dedd37460c
commit 986a505131
8 changed files with 432 additions and 7 deletions

View file

@ -18,6 +18,7 @@ from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.constants import REDACTED_BY_LITELLM
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
from litellm.llms.anthropic.mid_conversation_system import anthropic_system_messages
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client
from litellm.types.files import get_file_extension_from_mime_type
from litellm.types.llms.anthropic import *
@ -2305,14 +2306,20 @@ def _drop_unsignable_thinking_blocks(
return [block for block in thinking_blocks if not _is_unsignable_thinking_block(block)]
# mutable-ok: anthropic_messages_pt's callers have always appended to the list it returns
_AnthropicMessageList = list[AllAnthropicPassThroughMessageValues]
def anthropic_messages_pt(
messages: list[AllMessageValues],
model: str,
llm_provider: str,
) -> list[AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam]:
) -> _AnthropicMessageList:
"""
format messages for anthropic
1. Anthropic supports roles like "user" and "assistant" (system prompt sent separately)
1. Anthropic supports roles like "user" and "assistant" (system prompt sent separately).
Models flagged ``supports_mid_conversation_system`` also accept "system" inside
messages after a user turn; the caller decides placement, this keeps such messages.
2. The first message always needs to be of role "user"
3. Each message must alternate between "user" and "assistant" (this is not addressed as now by litellm)
4. final assistant content cannot end with trailing whitespace (anthropic raises an error otherwise)
@ -2337,7 +2344,7 @@ def anthropic_messages_pt(
# add role=tool support to allow function call result/error submission
user_message_types: Final = {"user", "tool", "function"}
# reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, merge them.
new_messages: Final[list[AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam]] = []
new_messages: Final[_AnthropicMessageList] = [] # mutable-ok: accumulator behind the mutable return contract
if len(messages) == 0:
if not litellm.modify_params:
@ -2730,6 +2737,11 @@ def anthropic_messages_pt(
if assistant_content:
new_messages.append({"role": "assistant", "content": assistant_content})
## MID-CONVERSATION SYSTEM MESSAGES (placement is the caller's job) ##
while msg_i < len(messages) and messages[msg_i]["role"] == "system":
new_messages.extend(anthropic_system_messages(messages[msg_i]))
msg_i += 1
if msg_i == init_msg_i: # prevent infinite loops
raise litellm.BadRequestError(
message=BAD_MESSAGE_ERROR_STR + f"passed in {messages[msg_i]}",

View file

@ -75,6 +75,7 @@ from litellm.types.utils import Message as LitellmMessage
from litellm.utils import (
ModelResponse,
Usage,
_supports_factory,
add_dummy_tool,
any_assistant_message_has_thinking_blocks,
get_max_tokens,
@ -90,6 +91,7 @@ from ..common_utils import (
process_anthropic_headers,
strip_advisor_blocks_from_messages,
)
from ..mid_conversation_system import place_mid_conversation_system, split_leading_system_run
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -1897,16 +1899,29 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if _name_reverse_map and isinstance(litellm_params, dict):
litellm_params[ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY] = _name_reverse_map
# Separate system prompt from rest of message
anthropic_system_message_list: Final = self.translate_system_message(messages=messages)
# Only the leading system run becomes the top-level system prompt. A later
# system message stays in the conversation: hoisting it rewrites the cached
# prefix and re-bills the whole history at cache-write pricing (#36559).
leading_system_run, later_messages = split_leading_system_run(messages)
anthropic_system_message_list: Final = self.translate_system_message(
messages=list(leading_system_run) # mutable-ok: translate_system_message pops from the list it is given
)
# Handling anthropic API Prompt Caching
if len(anthropic_system_message_list) > 0:
optional_params["system"] = anthropic_system_message_list
conversation: Final = place_mid_conversation_system(
later_messages,
supports_mid_conversation_system=_supports_factory(
model=model,
custom_llm_provider=self.custom_llm_provider,
key="supports_mid_conversation_system",
),
)
# Format rest of message according to anthropic guidelines
try:
anthropic_messages = anthropic_messages_pt(
model=model,
messages=messages,
messages=list(conversation), # mutable-ok: anthropic_messages_pt rewrites entries in place
llm_provider=self._resolved_provider,
)
except Exception as e:

View file

@ -361,7 +361,8 @@ class AnthropicMessagesSystemMessageParam(TypedDict, total=False):
AllAnthropicMessageValues = AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam
# System is not a native Anthropic message role; only pass-through adapters use this union.
# role=system inside messages is accepted after a user turn on models flagged
# supports_mid_conversation_system; pass-through adapters and the chat translator both emit it.
AllAnthropicPassThroughMessageValues: TypeAlias = (
AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam | AnthropicMessagesSystemMessageParam
)

View file

@ -3578,3 +3578,38 @@ async def test_bedrock_converse_pdf_only_user_message_gets_text_block_async():
assert len(result) == 1
assert any("document" in block for block in result[0]["content"])
assert _text_blocks(result[0]) == [BEDROCK_DOCUMENT_PLACEHOLDER_TEXT]
def test_anthropic_messages_pt_keeps_system_role_after_user_turn():
"""Models flagged supports_mid_conversation_system accept role=system inside
messages; the converter must emit it as a system message with its text
blocks and cache_control intact instead of rejecting the role."""
messages = [
{"role": "user", "content": "First question"},
{
"role": "system",
"content": [{"type": "text", "text": "Answer in one word.", "cache_control": {"type": "ephemeral"}}],
},
{"role": "assistant", "content": "Yes"},
{"role": "user", "content": "Second question"},
]
result = anthropic_messages_pt(messages=messages, model="claude-opus-4-8", llm_provider="anthropic")
assert [m["role"] for m in result] == ["user", "system", "assistant", "user"]
assert result[1] == {
"role": "system",
"content": [{"type": "text", "text": "Answer in one word.", "cache_control": {"type": "ephemeral"}}],
}
def test_anthropic_messages_pt_system_string_content_becomes_text_block():
messages = [
{"role": "user", "content": "First question"},
{"role": "system", "content": "Answer in one word."},
]
result = anthropic_messages_pt(messages=messages, model="claude-opus-4-8", llm_provider="anthropic")
assert result[1] == {"role": "system", "content": [{"type": "text", "text": "Answer in one word."}]}

View file

@ -1,4 +1,5 @@
import copy
import pytest
from unittest.mock import MagicMock, patch
@ -17,6 +18,13 @@ from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.azure_ai.anthropic.transformation import AzureAnthropicConfig
from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeConfig,
)
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import (
VertexAIAnthropicConfig,
)
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
from litellm.types.utils import ServerToolUse, Usage
@ -6207,3 +6215,213 @@ def test_disabled_thinking_omitted_only_for_always_on_models(
assert "thinking" not in request
else:
assert request["thinking"] == {"type": "disabled"}
# ---------------------------------------------------------------------------
# Mid-conversation ``role: "system"`` on the chat completions path.
#
# Hoisting a later system message into the top-level ``system`` block rewrites
# the cached prefix and re-bills the whole conversation at cache-write pricing
# on every reminder (#36559). The chat path must keep the prefix stable: leading
# system messages still become the ``system`` param, later ones stay in place as
# ``role: "system"`` on models flagged ``supports_mid_conversation_system`` and
# become a user turn on models that reject the role inside ``messages``.
# ---------------------------------------------------------------------------
UNFLAGGED_CLAUDE = "claude-opus-4-7"
FLAGGED_CLAUDE = "claude-opus-4-8"
CONVERTED_SYSTEM_NOTE = (
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
)
REMINDER_TEXT = "<system-reminder>Answer with exactly one word.</system-reminder>"
CACHED_SYSTEM_BLOCK = {"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}
def _chat_request(config, model, messages):
return config.transform_request(
model=model,
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
def _reminder_conversation():
"""The shape Claude Code sends mid-session: cached system prompt, turns, a
reminder right after a user turn, an assistant turn, a fresh user turn."""
return [
{"role": "system", "content": [dict(CACHED_SYSTEM_BLOCK)]},
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
{"role": "system", "content": REMINDER_TEXT},
{"role": "assistant", "content": "Second answer"},
{"role": "user", "content": "Third question"},
]
def _texts(message):
return [block["text"] for block in message["content"] if block.get("type") == "text"]
def test_chat_unflagged_model_converts_mid_conversation_system_to_user_turn(local_model_cost_map):
result = _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, _reminder_conversation())
assert result["system"] == [CACHED_SYSTEM_BLOCK]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
assert _texts(result["messages"][2]) == ["Second question", CONVERTED_SYSTEM_NOTE, REMINDER_TEXT]
def test_chat_flagged_model_keeps_mid_conversation_system_in_messages(local_model_cost_map):
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, _reminder_conversation())
assert result["system"] == [CACHED_SYSTEM_BLOCK]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
assert result["messages"][3] == {"role": "system", "content": [{"type": "text", "text": REMINDER_TEXT}]}
def test_chat_flagged_model_keeps_cache_control_on_mid_conversation_system(local_model_cost_map):
messages = _reminder_conversation()
messages[4] = {
"role": "system",
"content": [{"type": "text", "text": REMINDER_TEXT, "cache_control": {"type": "ephemeral"}}],
}
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
assert result["messages"][3]["content"] == [
{"type": "text", "text": REMINDER_TEXT, "cache_control": {"type": "ephemeral"}}
]
def test_chat_flagged_model_moves_system_after_the_user_turn_it_precedes(local_model_cost_map):
"""Anthropic only accepts role=system directly after a user turn; an
OpenAI-shaped client that puts the reminder before its next question gets a
placement-valid request without the reminder leaving ``messages``."""
messages = [
{"role": "system", "content": "You are terse."},
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "system", "content": REMINDER_TEXT},
{"role": "user", "content": "Second question"},
]
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system"]
assert _texts(result["messages"][2]) == ["Second question"]
assert _texts(result["messages"][3]) == [REMINDER_TEXT]
def test_chat_flagged_model_converts_system_with_no_following_user_turn(local_model_cost_map):
messages = [
{"role": "system", "content": "You are terse."},
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "system", "content": REMINDER_TEXT},
]
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user"]
assert _texts(result["messages"][2]) == [CONVERTED_SYSTEM_NOTE, REMINDER_TEXT]
def test_chat_flagged_model_merges_adjacent_system_messages(local_model_cost_map):
messages = [
{"role": "system", "content": "You are terse."},
{"role": "user", "content": "First question"},
{"role": "system", "content": "Reminder one."},
{"role": "system", "content": "Reminder two."},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
]
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
assert [m["role"] for m in result["messages"]] == ["user", "system", "assistant", "user"]
assert _texts(result["messages"][1]) == ["Reminder one.", "Reminder two."]
def test_chat_unflagged_model_keeps_tool_result_first_when_system_precedes_tool_message(local_model_cost_map):
messages = [
{"role": "system", "content": "You are terse."},
{"role": "user", "content": "Weather?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
}
],
},
{"role": "system", "content": REMINDER_TEXT},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
{"role": "user", "content": "Thanks"},
]
result = _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, messages)
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user"]
blocks = result["messages"][2]["content"]
assert blocks[0]["type"] == "tool_result"
assert blocks[0]["tool_use_id"] == "call_1"
assert _texts(result["messages"][2]) == [CONVERTED_SYSTEM_NOTE, REMINDER_TEXT, "Thanks"]
def test_chat_transform_request_does_not_mutate_caller_messages(local_model_cost_map):
messages = _reminder_conversation()
snapshot = copy.deepcopy(messages)
_chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, messages)
assert messages == snapshot
_CHAT_CONFIGS = [
pytest.param(AnthropicConfig, UNFLAGGED_CLAUDE, id="anthropic-unflagged"),
pytest.param(AnthropicConfig, FLAGGED_CLAUDE, id="anthropic-flagged"),
pytest.param(VertexAIAnthropicConfig, UNFLAGGED_CLAUDE, id="vertex_ai-unflagged"),
pytest.param(VertexAIAnthropicConfig, FLAGGED_CLAUDE, id="vertex_ai-flagged"),
pytest.param(AzureAnthropicConfig, UNFLAGGED_CLAUDE, id="azure_ai-unflagged"),
pytest.param(AzureAnthropicConfig, FLAGGED_CLAUDE, id="azure_ai-flagged"),
pytest.param(AmazonAnthropicClaudeConfig, "invoke/us.anthropic.claude-opus-4-7", id="bedrock_invoke-unflagged"),
pytest.param(AmazonAnthropicClaudeConfig, "invoke/us.anthropic.claude-opus-4-8", id="bedrock_invoke-flagged"),
]
@pytest.mark.parametrize("config_cls, model", _CHAT_CONFIGS)
def test_chat_mid_conversation_system_keeps_earlier_turns_a_prefix_of_the_next_request(
local_model_cost_map, config_cls, model
):
"""The provider-side prompt cache is a prefix match over ``system`` +
``messages``. Whatever the policy for the reminder, turn N's request must
stay a prefix of turn N+1's request or the whole conversation is re-billed.
Anthropic combines consecutive same-role messages into one turn, so the
cache-relevant sequence is ``(role, content block)`` pairs, not the message
list: a reminder that joins the preceding user turn still extends the prefix.
"""
conversation = _reminder_conversation()
earlier = _chat_request(config_cls(), model, copy.deepcopy(conversation[:4]))
later = _chat_request(config_cls(), model, copy.deepcopy(conversation))
assert later["system"] == earlier["system"]
earlier_blocks = _role_block_pairs(earlier["messages"])
later_blocks = _role_block_pairs(later["messages"])
assert later_blocks[: len(earlier_blocks)] == earlier_blocks
assert len(later_blocks) > len(earlier_blocks)
def _role_block_pairs(messages):
return [
(message["role"], block)
for message in messages
for block in (message["content"] if isinstance(message["content"], list) else [message["content"]])
]

View file

@ -413,3 +413,51 @@ class TestAzureAnthropicConfig:
assert "anthropic-beta" in headers
assert "compact-2026-01-12" in headers["anthropic-beta"]
assert "context-management-2025-06-27" in headers["anthropic-beta"]
def _mid_conversation_system_conversation():
return [
{"role": "system", "content": [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]},
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
{"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"},
{"role": "assistant", "content": "Second answer"},
{"role": "user", "content": "Third question"},
]
def test_chat_unflagged_model_converts_mid_conversation_system_instead_of_hoisting(local_model_cost_map):
"""A hoisted reminder rewrites the top-level system block and invalidates the
prompt cache for the whole conversation (#36559)."""
result = AzureAnthropicConfig().transform_request(
model="claude-opus-4-7",
messages=_mid_conversation_system_conversation(),
optional_params={},
litellm_params={},
headers={},
)
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
texts = [b["text"] for b in result["messages"][2]["content"] if b.get("type") == "text"]
assert texts[0] == "Second question"
assert texts[-1] == "<system-reminder>Answer with exactly one word.</system-reminder>"
def test_chat_flagged_model_keeps_mid_conversation_system_role_in_place(local_model_cost_map):
result = AzureAnthropicConfig().transform_request(
model="claude-opus-4-8",
messages=_mid_conversation_system_conversation(),
optional_params={},
litellm_params={},
headers={},
)
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
assert result["messages"][3] == {
"role": "system",
"content": [{"type": "text", "text": "<system-reminder>Answer with exactly one word.</system-reminder>"}],
}

View file

@ -542,3 +542,51 @@ def test_output_format_removed_from_bedrock_invoke_request():
assert (
"output_format" not in result
), f"output_format should be removed for Bedrock Invoke, got keys: {result.keys()}"
def _mid_conversation_system_conversation():
return [
{"role": "system", "content": [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]},
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
{"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"},
{"role": "assistant", "content": "Second answer"},
{"role": "user", "content": "Third question"},
]
def test_chat_unflagged_model_converts_mid_conversation_system_instead_of_hoisting(local_model_cost_map):
"""A hoisted reminder rewrites the top-level system block and invalidates the
prompt cache for the whole conversation (#36559)."""
result = AmazonAnthropicClaudeConfig().transform_request(
model="invoke/us.anthropic.claude-opus-4-7",
messages=_mid_conversation_system_conversation(),
optional_params={},
litellm_params={},
headers={},
)
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
texts = [b["text"] for b in result["messages"][2]["content"] if b.get("type") == "text"]
assert texts[0] == "Second question"
assert texts[-1] == "<system-reminder>Answer with exactly one word.</system-reminder>"
def test_chat_flagged_model_keeps_mid_conversation_system_role_in_place(local_model_cost_map):
result = AmazonAnthropicClaudeConfig().transform_request(
model="invoke/us.anthropic.claude-opus-4-8",
messages=_mid_conversation_system_conversation(),
optional_params={},
litellm_params={},
headers={},
)
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
assert result["messages"][3] == {
"role": "system",
"content": [{"type": "text", "text": "<system-reminder>Answer with exactly one word.</system-reminder>"}],
}

View file

@ -727,3 +727,51 @@ def test_sanitize_strips_effort_for_haiku_45():
data = {"output_config": {"effort": "high"}}
sanitize_vertex_anthropic_output_params(data, "vertex_ai/claude-opus-4-6")
assert data["output_config"] == {"effort": "high"}
def _mid_conversation_system_conversation():
return [
{"role": "system", "content": [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]},
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
{"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"},
{"role": "assistant", "content": "Second answer"},
{"role": "user", "content": "Third question"},
]
def test_chat_unflagged_model_converts_mid_conversation_system_instead_of_hoisting(local_model_cost_map):
"""A hoisted reminder rewrites the top-level system block and invalidates the
prompt cache for the whole conversation (#36559)."""
result = VertexAIAnthropicConfig().transform_request(
model="claude-opus-4-7",
messages=_mid_conversation_system_conversation(),
optional_params={},
litellm_params={},
headers={},
)
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
texts = [b["text"] for b in result["messages"][2]["content"] if b.get("type") == "text"]
assert texts[0] == "Second question"
assert texts[-1] == "<system-reminder>Answer with exactly one word.</system-reminder>"
def test_chat_flagged_model_keeps_mid_conversation_system_role_in_place(local_model_cost_map):
result = VertexAIAnthropicConfig().transform_request(
model="claude-opus-4-8",
messages=_mid_conversation_system_conversation(),
optional_params={},
litellm_params={},
headers={},
)
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
assert result["messages"][3] == {
"role": "system",
"content": [{"type": "text", "text": "<system-reminder>Answer with exactly one word.</system-reminder>"}],
}