diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py
index 86cfbf70255..4cd8a689afa 100644
--- a/litellm/litellm_core_utils/prompt_templates/factory.py
+++ b/litellm/litellm_core_utils/prompt_templates/factory.py
@@ -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]}",
diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py
index 15fc482b34e..b442d27c91e 100644
--- a/litellm/llms/anthropic/chat/transformation.py
+++ b/litellm/llms/anthropic/chat/transformation.py
@@ -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:
diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py
index f127366cc21..a1cd2fa9ca1 100644
--- a/litellm/types/llms/anthropic.py
+++ b/litellm/types/llms/anthropic.py
@@ -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
)
diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py
index 6265779b90d..cf85329aa12 100644
--- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py
+++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py
@@ -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."}]}
+
diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py
index 25e2c3cda80..74d2c22bdf3 100644
--- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py
+++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py
@@ -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 = "Answer with exactly one word."
+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"]])
+ ]
+
diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py
index 9dac914ca4d..257d5f92ee5 100644
--- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py
+++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py
@@ -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": "Answer with exactly one word."},
+ {"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] == "Answer with exactly one word."
+
+
+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": "Answer with exactly one word."}],
+ }
+
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
index cea299280f8..080050c7621 100644
--- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
+++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
@@ -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": "Answer with exactly one word."},
+ {"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] == "Answer with exactly one word."
+
+
+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": "Answer with exactly one word."}],
+ }
+
diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py
index 552ca98441f..65a77364bc7 100644
--- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py
+++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py
@@ -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": "Answer with exactly one word."},
+ {"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] == "Answer with exactly one word."
+
+
+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": "Answer with exactly one word."}],
+ }
+