fix(anthropic): keep the replayed prefix byte-stable for preserved thinking on chat completions (#42630)

* feat(anthropic): placement policy for mid-conversation system messages

Pure functions over the OpenAI-format message list: split off the leading
system run, keep later system messages as role=system at a placement Anthropic
accepts on models flagged supports_mid_conversation_system (after a user turn,
before an assistant turn or the end, never adjacent), and convert them to user
turns in place elsewhere, keeping tool_result first in a merged user turn.

* 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.

* refactor(anthropic): single-source the converted system note

The /v1/messages pass-through and the chat completions path must prefix a
converted system turn with the same operator note.

* test(e2e): prove the prompt cache survives a mid-conversation system reminder on chat completions

Same priming and assertions as the /v1/messages cases, through
/v1/chat/completions with OpenAI-format messages, for first-party Anthropic
and Bedrock Invoke on a flagged (Opus 4.8) and an unflagged (Haiku 4.5) model.
The reminder sits between the assistant turn and the next user turn, the shape
OpenAI-style agent frameworks send, which is the placement the chat path has
to translate.

* test(anthropic): cover the cache_control rebuild shapes and type the test helpers

Codecov flagged the 5m ttl branch and the empty-system path of the wire
builder; both now have a test. Greptile asked for full typing on the new
test helpers.

* refactor(anthropic): read the mid-conversation flag through a public supports_ helper

supports_mid_conversation_system joins the other supports_* helpers in
litellm.utils, so the chat transformation stops importing the private
_supports_factory.

* chore(typing): declare the mid-conversation type aliases with TypeAlias

The Final sweep tightened LIT010, which exempts TypeAlias declarations but
counts a bare alias assignment as an unannotated binding.

* fix(anthropic): let add_code_execution_tool take the pass-through message union

The translator now emits role=system inside messages for models that accept it,
so anthropic_messages_pt returns the pass-through union. add_code_execution_tool
still declared the narrower user/assistant union while only ever reading
content, so upstream's strip_advisor_blocks_from_messages call in between made
the mismatch visible to the type checker.

* fix(bedrock): keep mid-conversation system messages in place on converse path

* fix: ruff format + multi tool_result order + regression test

* fix: satisfy type-discipline gate + update osv ignore for mlflow PYSEC-2026-3865

* fix(bedrock): restore role narrowing in hoisted system loop for basedpyright budget

* test(bedrock): cover mid-conversation system conversion branches

- non-dict guard in _opens_with_tool_result
- in-place conversion without tool context
- str/list cache_control preservation in mid-conversation path
- drop unreachable non-system guard in hoisted loop

* Place type-discipline suppressions on the lines the gate scans

* Narrow hoisted loop to system role so basedpyright sees the right TypedDict

* fix(anthropic): place mid-conversation system runs by their neighbours only

A run after an assistant turn now slides behind the user turn that
immediately follows it, and a run that ends the array or precedes an
assistant turn becomes a user turn in place. No later message can move
an earlier run, so a client that replays the conversation with more
turns appended sends a byte-identical prefix and preserved thinking
blocks keep their binding

* refactor(bedrock): share the converted system note with the anthropic module

Converse imports CONVERTED_SYSTEM_NOTE instead of carrying its own copy
of the same text, and the reordering helpers lose their comments

* test: pin the replayed request prefix across preserved-thinking turns

One test per audited feature, through the real entrypoint: the chat
transformations for anthropic, bedrock invoke, vertex and converse, the
modify_params dummy tool result, dotprompt with unchanged variables, and
Presidio masking against an in-process fake. Each serializes system,
tools and the earlier messages of turn N and N+1 and asserts they match.
The e2e mid-conversation system test imports its content blocks from
models.py again and is marked provider_live

* fix(anthropic): move mid-conversation system placement into prompt_templates

The prompt factory imported the placement helper from the Anthropic provider
package, whose common_utils reads a factory constant at import time, so loading
the factory first raised ImportError. The module now sits next to
anthropic_messages_pt and every consumer imports core utils

A user turn with content [] or None puts no block on the wire, so a system run
anchored to it landed first in messages or behind an assistant turn. Such a run
now converts in place; empty strings and empty text blocks still anchor because
the factory fills them with a placeholder

* fix(anthropic): anchor system messages only on user turns that reach the wire

* fix(bedrock): type the converse system-message helpers over the message TypedDicts

* fix(anthropic): read replayed pydantic messages in the Converse helpers and convert a system run whose assistant follower sends nothing

A history that replays the previous turn as the litellm.Message object
was invisible to the Converse system-message helpers, so a mid-conversation
system stayed between a tool call and its result or reached Converse as
role: system. The helpers now read fields through the shared
message_field and parts_of accessors and drop the local role predicate.

Flagged placement anchored a system run on any assistant follower, but
anthropic_messages_pt drops an assistant turn that puts no block on the
wire (content None, an empty list, an unsigned thinking part), so the
system landed directly before the next user turn, which Anthropic
rejects. Such a run now converts in place. An empty or whitespace text
turn still anchors, since the converter pads it with a placeholder.

* fix(anthropic): treat bridged encrypted reasoning as a vanishing assistant turn for system placement

An assistant turn whose only blocks carry Responses API encrypted reasoning is
dropped by anthropic_messages_pt, so a mid-conversation system run anchored
before it landed directly before the next user turn. The unsignable-thinking
predicate now lives in common_utils and both the factory and the placement
policy consult it.

* fix(anthropic): let an inline thinking part hide separate thinking_blocks in system placement

anthropic_messages_pt skips an assistant turn's separate thinking_blocks as soon
as its content list carries an inline thinking or redacted_thinking part, so a
turn whose inline part is unsigned puts nothing on the wire even when the
separate block is signed. The placement policy now mirrors that rule.

---------

Co-authored-by: Shifat Islam Santo <shifatislamsanto764@gmail.com>
Co-authored-by: ege-arhan <egearhany@gmail.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-24 22:01:20 -07:00 • committed by GitHub
parent c601dfc134
commit 020e5dee9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2619 additions and 374 deletions

View file

@ -1989,6 +1989,26 @@ def is_encrypted_reasoning_block(block: object) -> bool:
return _carries_encrypted_reasoning(_encrypted_reasoning_field(mapping))
def is_unsignable_thinking_block(block: object) -> bool:
"""A thinking block Anthropic cannot accept on input.
Anthropic verifies the thinking signature cryptographically, so a block whose
signature is null, empty, or missing (e.g. from an open-source reasoning model)
is rejected with a 400 and must be dropped rather than blanked or repaired, and
so is a block whose signature or data carries another provider's encrypted
reasoning. A `redacted_thinking` block Anthropic minted is always kept.
"""
if is_encrypted_reasoning_block(block):
return True
if not isinstance(block, Mapping):
return False
mapping: Final = cast(Mapping[str, object], block) # cast-ok: narrowed by isinstance
if mapping.get("type") != "thinking":
return False
signature: Final = mapping.get("signature")
return not (isinstance(signature, str) and len(signature) > 0)
def strip_encrypted_reasoning_from_messages(messages: object) -> None:
"""Drop the bridge-tagged reasoning blocks a routed deployment cannot decrypt from
Anthropic-shaped history.

View file

@ -7,7 +7,7 @@ import re
import xml.etree.ElementTree as ET
from collections.abc import Iterator, Mapping, Sequence
from enum import Enum
from typing import Any, Final, TypedDict, cast, overload
from typing import Any, Final, TypeAlias, TypedDict, cast, overload
from jinja2.sandbox import ImmutableSandboxedEnvironment
@ -17,6 +17,7 @@ import litellm.types.llms
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.constants import REDACTED_BY_LITELLM
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import anthropic_system_messages
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client
from litellm.types.files import get_file_extension_from_mime_type
@ -48,8 +49,8 @@ from litellm.types.utils import GenericImageParsingChunk
from .common_utils import (
convert_content_list_to_str,
infer_content_type_from_url_and_content,
is_encrypted_reasoning_block,
is_non_content_values_set,
is_unsignable_thinking_block,
parse_tool_call_arguments,
)
from .image_handling import convert_url_to_base64
@ -2329,37 +2330,25 @@ def sanitize_messages_for_tool_calling(
return sanitized_messages
def _is_unsignable_thinking_block(block: object) -> bool:
"""A thinking block that Anthropic cannot accept on input.
Anthropic verifies the thinking signature cryptographically, so a block whose
signature is null, empty, or missing (e.g. from an open-source reasoning model)
is rejected with a 400 and must be dropped rather than blanked or repaired, and
so is a block whose signature or data carries another provider's encrypted
reasoning. A `redacted_thinking` block Anthropic minted is always kept.
"""
if is_encrypted_reasoning_block(block):
return True
if not isinstance(block, dict) or block.get("type") != "thinking":
return False
signature: Final = block.get("signature")
return not (isinstance(signature, str) and len(signature) > 0)
def _drop_unsignable_thinking_blocks(
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock],
) -> list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock]:
return [block for block in thinking_blocks if not _is_unsignable_thinking_block(block)]
return [block for block in thinking_blocks if not is_unsignable_thinking_block(block)]
_AnthropicMessageList: TypeAlias = 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)
@ -2384,7 +2373,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:
@ -2697,7 +2686,7 @@ def anthropic_messages_pt(
if (
m.get("type", "") == "thinking"
and len(thinking_block) > 0
and not _is_unsignable_thinking_block(m)
and not is_unsignable_thinking_block(m)
): # don't pass empty text blocks. anthropic api raises errors.
anthropic_message: ChatCompletionThinkingBlock | AnthropicMessagesTextParam = cast(
ChatCompletionThinkingBlock, m
@ -2777,6 +2766,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

@ -0,0 +1,418 @@
"""Placement policy for ``role: "system"`` messages that appear after the first turn
of an Anthropic-shaped chat completions request.
Only the leading run of system messages belongs in the top-level ``system``
parameter. Hoisting a later one there rewrites the cached prefix, so the provider
re-bills the whole conversation at cache-write pricing on every reminder (#36559).
Models flagged ``supports_mid_conversation_system`` in the cost map accept the role
inside ``messages`` under Anthropic's placement rules: the message must directly
follow a user turn, must be the last entry or be followed by an assistant turn, and
must not sit next to another system message. OpenAI-shaped clients put system
messages anywhere, so this module places each run by its neighbours alone: a run
after a user turn stays with that turn, a run after an assistant turn slides
behind the user turn that immediately follows it, and a run that ends the array
or precedes an assistant turn becomes a user turn in place. Runs that land on the
same slot merge into one system message. No later message can move an earlier
run, so a client that replays the conversation with more turns appended sends a
byte-identical prefix and preserved thinking blocks keep their binding.
Models without the flag reject the role inside ``messages``. Their system messages
become user turns in place, prefixed with an operator note so the model can tell
the instruction apart from the user's own words. A run caught between a tool call
and its result moves to just after the result so the ``tool_result`` block stays
first in the merged user turn.
Every transformation here is a pure function of the message sequence: turn N's
output stays a prefix of turn N+1's output, which is what keeps the provider-side
prompt cache readable across turns. Messages are handled in OpenAI format; the
Anthropic wire shape is built later by ``anthropic_messages_pt``.
"""
from collections.abc import Iterator, Mapping, Sequence
from itertools import chain, groupby
from typing import Final, Literal, TypeAlias
from litellm.types.llms.anthropic import AnthropicMessagesSystemMessageParam, AnthropicSystemMessageContent
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionCachedContent,
ChatCompletionSystemMessage,
ChatCompletionTextObject,
ChatCompletionUserMessage,
)
from .common_utils import is_unsignable_thinking_block
CONVERTED_SYSTEM_NOTE: Final = (
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
)
_USER_TYPE_ROLES: Final = frozenset({"user", "tool", "function"})
_TOOL_ROLES: Final = frozenset({"tool", "function"})
_RENDERED_PART_TYPES: Final = frozenset({"text", "image_url", "document", "file"})
_RENDERED_ASSISTANT_PART_TYPES: Final = frozenset({"text", "server_tool_use"})
_THINKING_BLOCK_TYPES: Final = frozenset({"thinking", "redacted_thinking"})
_MessageKind: TypeAlias = Literal["system", "tool", "user", "other"]
_TextPart: TypeAlias = tuple[str, ChatCompletionCachedContent | None]
def _as_mapping(value: object) -> Mapping[str, object] | None:
return value if isinstance(value, Mapping) else None
def parts_of(value: object) -> tuple[object, ...]:
return tuple(value) if isinstance(value, Sequence) and not isinstance(value, str) else ()
def message_field(message: object, key: str) -> object:
"""A message field, whether the message is a dict or a pydantic ``Message``.
Clients replay assistant turns straight from a response, so a history mixes
plain dicts with ``litellm.Message`` objects; every predicate reads through here.
"""
mapping: Final = _as_mapping(message)
return mapping.get(key) if mapping is not None else getattr(message, key, None)
def is_system_message(message: object) -> bool:
return message_field(message, "role") == "system"
def _is_user_type(message: object) -> bool:
return message_field(message, "role") in _USER_TYPE_ROLES
def _kind(message: object) -> _MessageKind:
role: Final = message_field(message, "role")
if role == "system":
return "system"
if role in _TOOL_ROLES:
return "tool"
if role == "user":
return "user"
return "other"
def split_leading_system_run(
messages: Sequence[AllMessageValues],
) -> tuple[tuple[AllMessageValues, ...], tuple[AllMessageValues, ...]]:
"""Split ``messages`` into the leading run of system messages and everything after it."""
leading_count: Final = next(
(index for index, message in enumerate(messages) if not is_system_message(message)),
len(messages),
)
return tuple(messages[:leading_count]), tuple(messages[leading_count:])
def _cache_control(holder: object) -> ChatCompletionCachedContent | None:
"""The client's ``cache_control`` rebuilt in the only shape Anthropic accepts."""
value: Final = _as_mapping(message_field(holder, "cache_control"))
if value is None or value.get("type") != "ephemeral":
return None
ttl: Final = value.get("ttl")
if ttl == "1h":
one_hour: Final[ChatCompletionCachedContent] = {"type": "ephemeral", "ttl": "1h"}
return one_hour
if ttl == "5m":
five_minutes: Final[ChatCompletionCachedContent] = {"type": "ephemeral", "ttl": "5m"}
return five_minutes
ephemeral: Final[ChatCompletionCachedContent] = {"type": "ephemeral"}
return ephemeral
def _text_parts(message: object) -> tuple[_TextPart, ...]:
"""``(text, cache_control)`` for each non-empty text part of a system message.
Anthropic rejects empty text blocks and only accepts text in system content. A
``cache_control`` on the message itself belongs to the block built from string
content; block-level ``cache_control`` stays with its block.
"""
content: Final = message_field(message, "content")
if isinstance(content, str):
return ((content, _cache_control(message)),) if content else ()
return tuple(part for part in map(_text_part, parts_of(content)) if part is not None)
def _text_part(part: object) -> _TextPart | None:
if message_field(part, "type") != "text":
return None
text: Final = message_field(part, "text")
return (text, _cache_control(part)) if isinstance(text, str) and text else None
def _openai_text_block(part: _TextPart) -> ChatCompletionTextObject:
text, cache_control = part
if cache_control is None:
plain: Final[ChatCompletionTextObject] = {"type": "text", "text": text}
return plain
cached: Final[ChatCompletionTextObject] = {"type": "text", "text": text, "cache_control": cache_control}
return cached
def _anthropic_text_block(part: _TextPart) -> AnthropicSystemMessageContent:
text, cache_control = part
if cache_control is None:
plain: Final[AnthropicSystemMessageContent] = {"type": "text", "text": text}
return plain
cached: Final[AnthropicSystemMessageContent] = {"type": "text", "text": text, "cache_control": cache_control}
return cached
def anthropic_system_messages(message: object) -> tuple[AnthropicMessagesSystemMessageParam, ...]:
"""The Anthropic wire message for a system message, or nothing when it carries no text."""
blocks: Final = tuple(_anthropic_text_block(part) for part in _text_parts(message))
if not blocks:
return ()
wire: Final[AnthropicMessagesSystemMessageParam] = {
"role": "system",
"content": list(blocks), # mutable-ok: wire payload; cache_control hooks edit content blocks in place
}
return (wire,)
def system_message_as_user(message: object) -> ChatCompletionUserMessage:
"""A system message re-rolled as a user turn, prefixed with the operator note."""
note: Final[ChatCompletionTextObject] = {"type": "text", "text": CONVERTED_SYSTEM_NOTE}
content: Final[list[ChatCompletionTextObject]] = [ # mutable-ok: anthropic_messages_pt only recognises list content
note,
*(_openai_text_block(part) for part in _text_parts(message)),
]
turn: Final[ChatCompletionUserMessage] = {"role": "user", "content": content}
return turn
def _merged_system_message(run: Sequence[object]) -> tuple[ChatCompletionSystemMessage, ...]:
parts: Final = tuple(chain.from_iterable(_text_parts(message) for message in run))
if not parts:
return ()
content: Final[list[ChatCompletionTextObject]] = [ # mutable-ok: anthropic_messages_pt only recognises list content
_openai_text_block(part) for part in parts
]
merged: Final[ChatCompletionSystemMessage] = {"role": "system", "content": content}
return (merged,)
def _converted_user_turns(run: Sequence[object]) -> tuple[ChatCompletionUserMessage, ...]:
return tuple(system_message_as_user(message) for message in run if _text_parts(message))
def _runs(messages: Sequence[AllMessageValues]) -> tuple[tuple[_MessageKind, tuple[AllMessageValues, ...]], ...]:
return tuple((kind, tuple(group)) for kind, group in groupby(messages, key=_kind))
def _converted_for_unflagged_model(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
"""Convert every system message to a user turn in place.
A system run whose follower is a tool message is emitted after that tool run:
``tool_result`` blocks have to open the merged user turn.
"""
runs: Final = _runs(messages)
def emit(index: int) -> tuple[AllMessageValues, ...]:
kind, run = runs[index]
follower: Final = runs[index + 1][0] if index + 1 < len(runs) else None
if kind == "system":
return () if follower == "tool" else _converted_user_turns(run)
if kind == "tool" and index > 0 and runs[index - 1][0] == "system":
return (*run, *_converted_user_turns(runs[index - 1][1]))
return run
return tuple(chain.from_iterable(emit(index) for index in range(len(runs))))
def _user_type_blocks(messages: Sequence[AllMessageValues]) -> tuple[tuple[bool, tuple[int, ...]], ...]:
"""Maximal groups of consecutive non-system messages, keyed by whether they are user-type.
Consecutive user-type messages become one user turn on the wire, so a group is
the unit a system message can validly follow.
"""
indexed: Final = tuple((index, message) for index, message in enumerate(messages) if not is_system_message(message))
return tuple(
(is_user, tuple(index for index, _ in group))
for is_user, group in groupby(indexed, key=lambda pair: _is_user_type(pair[1]))
)
def _system_runs(messages: Sequence[AllMessageValues]) -> tuple[tuple[int, ...], ...]:
"""Index runs of consecutive system messages."""
system_indices: Final = tuple(index for index, message in enumerate(messages) if is_system_message(message))
return tuple(
tuple(index for _, index in group)
for _, group in groupby(enumerate(system_indices), key=lambda pair: pair[1] - pair[0])
)
def _block_containing(message_index: int, blocks: Sequence[tuple[bool, tuple[int, ...]]]) -> int:
return next(index for index, (_, indices) in enumerate(blocks) if message_index in indices)
def _thinking_block_renders(block: object) -> bool:
"""A thinking block the converter keeps: one Anthropic can verify, so never bridged encrypted reasoning."""
return message_field(block, "type") in _THINKING_BLOCK_TYPES and not is_unsignable_thinking_block(block)
def _assistant_part_renders(part: object) -> bool:
"""A text part always renders: the converter pads empty text with a placeholder."""
part_type: Final = message_field(part, "type")
if part_type == "thinking":
thinking: Final = message_field(part, "thinking")
return isinstance(thinking, str) and bool(thinking) and _thinking_block_renders(part)
return part_type in _RENDERED_ASSISTANT_PART_TYPES or (
isinstance(part_type, str) and part_type.endswith("_tool_result")
)
def _separate_thinking_blocks_render(message: object, parts: Sequence[object]) -> bool:
"""``thinking_blocks`` reach the wire only when no inline thinking part claims the slot.
The converter skips the separate blocks as soon as the content list carries a
``thinking`` or ``redacted_thinking`` part, whether or not that part itself renders.
"""
if any(message_field(part, "type") in _THINKING_BLOCK_TYPES for part in parts):
return False
return any(_thinking_block_renders(block) for block in parts_of(message_field(message, "thinking_blocks")))
def _assistant_renders(message: object) -> bool:
"""Whether ``anthropic_messages_pt`` puts a block on the wire for this assistant message.
String content (the converter pads an empty one with a placeholder), a text part,
a signed thinking part, a server tool part, tool calls, a function call, a kept
thinking block and compaction blocks each render. An assistant message with none
of them, such as ``content: None`` or an empty list, vanishes from the wire.
"""
content: Final = message_field(message, "content")
if isinstance(content, str):
return True
parts: Final = parts_of(content)
return (
any(_assistant_part_renders(part) for part in parts)
or _separate_thinking_blocks_render(message, parts)
or bool(message_field(message, "tool_calls"))
or bool(message_field(message, "function_call"))
or bool(message_field(message_field(message, "provider_specific_fields"), "compaction_blocks"))
)
def _renders(message: object) -> bool:
"""Whether ``anthropic_messages_pt`` puts a block on the wire for this message.
A tool message always becomes a ``tool_result`` and a user message with string
content always becomes a text block (empty text gets a placeholder). A user list
renders only through parts of a type the converter emits; ``None``, an empty list,
and a list of other parts vanish. Assistant messages follow ``_assistant_renders``.
"""
role: Final = message_field(message, "role")
if role in _TOOL_ROLES:
return True
if role == "assistant":
return _assistant_renders(message)
content: Final = message_field(message, "content")
return isinstance(content, str) or any(
message_field(part, "type") in _RENDERED_PART_TYPES for part in parts_of(content)
)
def _rendered_block(
message_index: int,
messages: Sequence[AllMessageValues],
blocks: Sequence[tuple[bool, tuple[int, ...]]],
) -> int | None:
block_index: Final = _block_containing(message_index, blocks)
_, indices = blocks[block_index]
return block_index if any(_renders(messages[index]) for index in indices) else None
def _system_may_follow(
block_index: int,
messages: Sequence[AllMessageValues],
blocks: Sequence[tuple[bool, tuple[int, ...]]],
) -> bool:
"""Whether a system message behind this block precedes an assistant turn or ends the array on the wire.
Blocks alternate between user-type and assistant, so the check is whether the
first later block that puts anything on the wire is an assistant block.
"""
return next(
(
not is_user
for is_user, indices in blocks[block_index + 1 :]
if any(_renders(messages[index]) for index in indices)
),
True,
)
def _anchor_block(
run: Sequence[int],
messages: Sequence[AllMessageValues],
blocks: Sequence[tuple[bool, tuple[int, ...]]],
) -> int | None:
"""The user-type block a system run must follow, or ``None`` when it converts in place.
The run never starts at 0: the leading system run was split off before this
policy runs, so the message before a run is always a non-system message. Only
the run's neighbours decide, so a request that replays these messages with more
turns appended places the run identically. A block that puts nothing on the wire
cannot anchor a run: the system message would land first or behind an assistant
turn, so the run converts in place instead. The same happens when the assistant
turn after the anchor puts nothing on the wire and a user turn follows it: the
system message would sit directly before that user turn, which Anthropic rejects.
"""
previous: Final = run[0] - 1
neighbour: Final = previous if _is_user_type(messages[previous]) else run[-1] + 1
if neighbour >= len(messages) or not _is_user_type(messages[neighbour]):
return None
block_index: Final = _rendered_block(neighbour, messages, blocks)
if block_index is None or not _system_may_follow(block_index, messages, blocks):
return None
return block_index
def _placed_for_flagged_model(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
"""Keep system messages as ``role: "system"`` at a placement Anthropic accepts.
A run already sitting after a user-type message stays with that user turn. A
run after an assistant turn moves behind the user turn that immediately follows
it. A run that ends the array or is followed by an assistant turn becomes user
turns in place, so replaying the same messages with more turns appended cannot
move it. Runs that share a user turn merge into one system message.
"""
blocks: Final = _user_type_blocks(messages)
anchors: Final = tuple((run, _anchor_block(run, messages, blocks)) for run in _system_runs(messages))
def messages_of(run: tuple[int, ...]) -> tuple[AllMessageValues, ...]:
return tuple(messages[index] for index in run)
def anchored_to(block_index: int) -> tuple[AllMessageValues, ...]:
anchored_runs: Final = tuple(run for run, anchor in anchors if anchor == block_index)
return tuple(chain.from_iterable(map(messages_of, anchored_runs)))
def converted_after(message_index: int) -> tuple[ChatCompletionUserMessage, ...]:
following_runs: Final = tuple(run for run, anchor in anchors if anchor is None and run[0] == message_index + 1)
return tuple(chain.from_iterable(_converted_user_turns(messages_of(run)) for run in following_runs))
def emit(block_index: int) -> Iterator[AllMessageValues]:
is_user, indices = blocks[block_index]
for index in indices:
yield messages[index]
yield from converted_after(index)
if is_user:
yield from _merged_system_message(anchored_to(block_index))
return tuple(chain.from_iterable(emit(block_index) for block_index in range(len(blocks))))
def place_mid_conversation_system(
messages: Sequence[AllMessageValues],
*,
supports_mid_conversation_system: bool,
) -> tuple[AllMessageValues, ...]:
"""Apply the placement policy to the messages after the leading system run."""
if not any(is_system_message(message) for message in messages):
return tuple(messages)
if supports_mid_conversation_system:
return _placed_for_flagged_model(messages)
return _converted_for_unflagged_model(messages)

View file

@ -31,13 +31,17 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import (
async_inline_remote_media,
inline_remote_image_urls,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
place_mid_conversation_system,
split_leading_system_run,
)
from litellm.llms.base_llm.base_utils import type_to_response_format_param
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.types.llms.anthropic import (
ANTHROPIC_ADVISOR_TOOL_TYPE,
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_HOSTED_TOOLS,
AllAnthropicMessageValues,
AllAnthropicPassThroughMessageValues,
AllAnthropicToolsValues,
AnthropicCodeExecutionTool,
AnthropicComputerTool,
@ -87,6 +91,7 @@ from litellm.utils import (
get_max_tokens,
has_tool_call_blocks,
last_assistant_with_tool_calls_has_no_thinking_blocks,
supports_mid_conversation_system,
supports_reasoning,
token_counter,
)
@ -1743,10 +1748,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
def add_code_execution_tool(
self,
messages: list[AllAnthropicMessageValues],
messages: list[AllAnthropicPassThroughMessageValues],
tools: list[AllAnthropicToolsValues | dict],
) -> list[AllAnthropicToolsValues | dict]:
"""if 'container_upload' in messages, add code_execution tool"""
"""if 'container_upload' in messages, add code_execution tool
Takes the pass-through union because the translator emits ``role: "system"``
in ``messages`` for models that accept it; only ``content`` is read here."""
add_code_execution_tool = False
for message in messages:
message_content = message.get("content", None)
@ -1966,16 +1974,27 @@ 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_mid_conversation_system(
model=model, custom_llm_provider=self.custom_llm_provider
),
)
# 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

@ -2,9 +2,7 @@ from collections.abc import Mapping, Sequence
from itertools import groupby
from typing import Final
CONVERTED_SYSTEM_NOTE: Final = (
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import CONVERTED_SYSTEM_NOTE
def as_system_content_blocks(value: object) -> list[object]:

View file

@ -7,7 +7,8 @@ import json
import re
import time
import types
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from itertools import chain
from typing import TYPE_CHECKING, Final, Literal, cast, overload
import httpx
@ -34,6 +35,12 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_tools_pt,
make_valid_bedrock_tool_name,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
CONVERTED_SYSTEM_NOTE,
is_system_message,
message_field,
parts_of,
)
from litellm.llms.anthropic.chat.transformation import (
DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING,
DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING,
@ -55,9 +62,11 @@ from litellm.types.llms.openai import (
ChatCompletionAnnotation,
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionCachedContent,
ChatCompletionRedactedThinkingBlock,
ChatCompletionResponseMessage,
ChatCompletionSystemMessage,
ChatCompletionTextObject,
ChatCompletionThinkingBlock,
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
@ -1343,30 +1352,157 @@ class AmazonConverseConfig(BaseConfig):
cache_point["ttl"] = ttl
return cache_point
@staticmethod
def _assistant_has_tool_calls(message: object) -> bool:
return message_field(message, "role") == "assistant" and bool(message_field(message, "tool_calls"))
@staticmethod
def _opens_with_tool_result(message: object) -> bool:
"""Whether the message starts a tool-result turn on Converse.
``_bedrock_converse_messages_pt`` builds ``toolResult`` blocks from ``tool``
messages only, so a ``function`` message never opens one."""
role: Final = message_field(message, "role")
if role == "tool":
return True
if role != "user":
return False
first_part: Final = next(iter(parts_of(message_field(message, "content"))), None)
return message_field(first_part, "type") == "tool_result"
def _system_run_before(self, messages: Sequence[AllMessageValues], index: int) -> Sequence[AllMessageValues]:
start: Final = next(
(j + 1 for j in range(index - 1, -1, -1) if not is_system_message(messages[j])),
0,
)
return messages[start:index]
def _system_run_end(self, messages: Sequence[AllMessageValues], index: int) -> int:
return next(
(j for j in range(index, len(messages)) if not is_system_message(messages[j])),
len(messages),
)
def _reordered_around_tool_results(
self, messages: Sequence[AllMessageValues], index: int
) -> tuple[AllMessageValues, ...]:
"""Move a system run wedged between an assistant tool-call turn and its
tool-result turn(s) to after the tool results.
A converted system entry becomes a user turn, and a user turn between
a tool call and its result would split them. Everything else stays in
place so the cached prefix stays byte-identical."""
message: Final = messages[index]
if self._opens_with_tool_result(message):
if index + 1 < len(messages) and self._opens_with_tool_result(messages[index + 1]):
return (message,)
tool_run_start: Final = next(
(j + 1 for j in range(index, -1, -1) if not self._opens_with_tool_result(messages[j])),
0,
)
run: Final = self._system_run_before(messages, tool_run_start)
prev_idx: Final = tool_run_start - len(run) - 1
if run and prev_idx >= 0 and self._assistant_has_tool_calls(messages[prev_idx]):
return (message, *run)
return (message,)
if not is_system_message(message):
return (message,)
run_start: Final = next(
(j + 1 for j in range(index - 1, -1, -1) if not is_system_message(messages[j])),
0,
)
run_end: Final = self._system_run_end(messages, index)
follower: Final = messages[run_end] if run_end < len(messages) else None
if (
follower is not None
and self._opens_with_tool_result(follower)
and run_start > 0
and self._assistant_has_tool_calls(messages[run_start - 1])
):
return ()
return (message,)
def _system_role_message_as_user(self, message: ChatCompletionSystemMessage) -> ChatCompletionUserMessage | None:
"""Convert a mid-conversation system entry to a user turn, in place.
The Converse API only accepts user/assistant roles in ``messages``,
so keeping the role is not an option. Hoisting it to the top-level
``system`` block would mutate the system prefix and collapse implicit
prompt caching; converting in place keeps everything before the entry
byte-identical. An entry that carries no text becomes ``None``."""
text_blocks: Final = self._converted_text_blocks(message)
if not text_blocks:
return None
note: Final = ChatCompletionTextObject(type="text", text=CONVERTED_SYSTEM_NOTE)
body: Final = [ # mutable-ok: _bedrock_converse_messages_pt narrows content with isinstance(list)
note,
*text_blocks,
]
return ChatCompletionUserMessage(role="user", content=body)
def _converted_or_kept(self, message: AllMessageValues) -> AllMessageValues | None:
if not is_system_message(message):
return message
return self._system_role_message_as_user(
cast(ChatCompletionSystemMessage, message) # cast-ok: the role is checked on the line above
)
def _converted_text_blocks(self, message: ChatCompletionSystemMessage) -> tuple[ChatCompletionTextObject, ...]:
content: Final = message["content"]
if isinstance(content, str):
return (self._converted_text_block(content, message.get("cache_control")),) if content else ()
parts: Final[Sequence[object]] = content or ()
return tuple(
self._converted_text_block(part["text"], part.get("cache_control"))
for part in map(self._text_part, parts)
if part is not None
)
@staticmethod
def _text_part(part: object) -> ChatCompletionTextObject | None:
if not isinstance(part, dict) or part.get("type") != "text" or not part.get("text"):
return None
return cast(ChatCompletionTextObject, part) # cast-ok: the shape is checked on the line above
@staticmethod
def _converted_text_block(text: str, cache_control: ChatCompletionCachedContent | None) -> ChatCompletionTextObject:
if cache_control is None:
return ChatCompletionTextObject(type="text", text=text)
return ChatCompletionTextObject(type="text", text=text, cache_control=cache_control)
def _transform_system_message(
self, messages: list[AllMessageValues], model: str | None = None
) -> tuple[list[AllMessageValues], list[SystemContentBlock]]:
system_prompt_indices: Final = []
leading_count: Final = next(
(i for i, m in enumerate(messages) if not is_system_message(m)),
len(messages),
)
hoisted: Final = messages[:leading_count]
remaining: Final = messages[leading_count:]
system_content_blocks: Final[list[SystemContentBlock]] = []
for idx, message in enumerate(messages):
if message["role"] == "system":
system_prompt_indices.append(idx)
if isinstance(message["content"], str) and message["content"]:
system_content_blocks.append(SystemContentBlock(text=message["content"]))
cache_block = self.get_cache_point_block(message, block_type="system", model=model)
if cache_block:
system_content_blocks.append(cache_block)
elif isinstance(message["content"], list):
for m in message["content"]:
if m.get("type") == "text" and m.get("text"):
system_content_blocks.append(SystemContentBlock(text=m["text"]))
cache_block = self.get_cache_point_block(m, block_type="system", model=model)
if cache_block:
system_content_blocks.append(cache_block)
if len(system_prompt_indices) > 0:
for idx in reversed(system_prompt_indices):
messages.pop(idx)
return messages, system_content_blocks
for message in hoisted:
if message["role"] != "system":
continue
if isinstance(message["content"], str) and message["content"]:
system_content_blocks.append(SystemContentBlock(text=message["content"]))
cache_block = self.get_cache_point_block(message, block_type="system", model=model)
if cache_block:
system_content_blocks.append(cache_block)
elif isinstance(message["content"], list):
for m in message["content"]:
if m.get("type") == "text" and m.get("text"):
system_content_blocks.append(SystemContentBlock(text=m["text"]))
cache_block = self.get_cache_point_block(m, block_type="system", model=model)
if cache_block:
system_content_blocks.append(cache_block)
reordered: Final = tuple(
chain.from_iterable(
self._reordered_around_tool_results(remaining, index) for index in range(len(remaining))
)
)
converted: Final = tuple(self._converted_or_kept(message) for message in reordered)
kept: Final = [message for message in converted if message is not None] # mutable-ok: converse pt takes a list
return kept, system_content_blocks
def _transform_inference_params(self, inference_params: dict) -> InferenceConfig:
if "top_k" in inference_params:

View file

@ -393,7 +393,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

@ -23,6 +23,8 @@
- {id: llm.chat_completions.anthropic.prompt_cache_5m.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: anthropic, capability: prompt_cache_5m, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Claude prompt caching"}
- {id: llm.chat_completions.anthropic.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: anthropic, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Claude extended thinking"}
- {id: llm.chat_completions.anthropic.structured_output.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: anthropic, capability: structured_output, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Claude response_schema"}
- {id: llm.chat_completions.anthropic.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: chat_completions, route: anthropic, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/anthropic/chat/transformation.py", rationale: "OpenAI-format chat to first-party Anthropic: flagged Claude 4.8+/5 must keep a mid-conversation role system reminder in messages; hoisting it into the top-level system field mutates the cached prefix and re-bills the conversation at cache-write pricing (#36559)", fail_before_fix: proven}
- {id: llm.chat_completions.anthropic.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: anthropic, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/anthropic/chat/transformation.py", rationale: "OpenAI-format chat to first-party Anthropic: Claude <= 4.7 and Haiku 4.5 reject role system inside messages, so unflagged models must convert a mid-conversation reminder to a user turn in place (hoisting collapses the prompt cache) and still answer (#36559)", fail_before_fix: proven}
- {id: llm.chat_completions.bedrock_converse.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "P0 route; Bedrock Converse unified"}
- {id: llm.chat_completions.bedrock_converse.basic.stream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: basic, streaming: stream, assertions: [works], source: "proxy_server.py:8455", rationale: "Streaming over Converse"}
- {id: llm.chat_completions.bedrock_converse.tool_use.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: tool_use, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Converse function_calling; AWS adoption"}
@ -34,6 +36,8 @@
- {id: llm.chat_completions.bedrock_converse.batch_deployment.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: batch_deployment, streaming: nonstream, assertions: [works], source: "types/utils.py bedrock_batch_litellm_params", rationale: "A deployment carrying the documented batch-only S3 keys (s3_access_key_id, s3_secret_access_key, s3_encryption_key_id) must still serve ordinary chat; unregistered keys fall into optional_params and are forwarded as additionalModelRequestFields, which Bedrock 400s and which puts the S3 secret in the request body and debug log (LIT-8290)", fail_before_fix: proven}
- {id: llm.chat_completions.bedrock_invoke.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "Regional inference-profile ids (us.anthropic.*) over the invoke route, the deployment shape behind a customer timeout report on v1.90.0"}
- {id: llm.chat_completions.bedrock_invoke.basic.stream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: basic, streaming: stream, assertions: [works], source: "proxy_server.py:8455", rationale: "Streaming with regional inference-profile ids over the invoke route"}
- {id: llm.chat_completions.bedrock_invoke.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/anthropic/chat/transformation.py", rationale: "OpenAI-format chat to Bedrock Invoke builds the Anthropic request through AnthropicConfig.transform_request, so flagged Claude 4.8+/5 must keep a mid-conversation role system reminder in messages; hoisting mutates the cached prefix and collapses the prompt cache (#36559)", fail_before_fix: proven}
- {id: llm.chat_completions.bedrock_invoke.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/anthropic/chat/transformation.py", rationale: "OpenAI-format chat to Bedrock Invoke: Claude <= 4.7 and Haiku 4.5 reject role system inside messages, so unflagged models must convert a mid-conversation reminder to a user turn in place (hoisting collapses the prompt cache) and still answer (#36559)", fail_before_fix: proven}
- {id: llm.chat_completions.vertex.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "P0 route; Vertex AI"}
- {id: llm.chat_completions.gemini.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: gemini, capability: basic, streaming: nonstream, assertions: [works], source: "test_chat_completions_regression_e2e.py", rationale: "Gemini OpenAI-compatible chat translation"}
- {id: llm.chat_completions.gemini.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: chat_completions, route: gemini, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "test_chat_completions_regression_e2e.py", rationale: "Gemini chat cost lands in SpendLogs"}

View file

@ -0,0 +1,322 @@
"""Live e2e: mid-conversation ``role: "system"`` handling on the OpenAI-format
/v1/chat/completions path is model-aware for first-party Anthropic and Bedrock
Invoke, both of which build the Anthropic request through
``AnthropicConfig.transform_request`` (#36559).
Only the leading run of system messages becomes the top-level ``system``
parameter. A ``role: "system"`` entry that appears later in ``messages`` used to
be hoisted into that same field, which rewrote the cached prefix and re-billed
the whole conversation at cache-write pricing on every reminder. Models flagged
``supports_mid_conversation_system`` in the cost map (Claude 4.8+ and the 5
family) must keep the reminder in ``messages`` as ``role: "system"``; models
without the flag (Claude 4.7 and older, Haiku 4.5) reject that role inside
``messages``, so the proxy must convert the reminder to a user turn in place,
prefixed with an operator note. Either way the prompt cache written on turn one
must be read back in full on turn two.
The conversation shape mirrors what an OpenAI-SDK client sends mid-session: a
cached system prompt, a user turn carrying its own ``cache_control`` breakpoint,
an assistant turn, a ``role: "system"`` reminder, and a fresh user turn. The
message-turn breakpoint is what makes the cache assertion able to fail: a cache
entry whose prefix spans ``system`` plus message turns is invalidated when the
reminder is hoisted (the ``system`` field mutates and a turn disappears from
``messages``), while an entry ending at the system block itself would survive
the hoist and mask the regression.
The provider-native ``cache_control`` request shape is not expressible with the
shared ``ChatBody`` (whose content parts carry no cache_control), so the body is
built from the typed content blocks shared in ``models.py``.
"""
from __future__ import annotations
import time
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import Result, unwrap
from lifecycle import ResourceManager
from models import CacheControl, ChatResponse, LiteLLMParamsBody, RichMessage, TextBlock, Usage
from passthrough_client import PassthroughClient
pytestmark = [pytest.mark.e2e, pytest.mark.provider_live]
CACHE_PRIMING_DEADLINE_SECONDS = 60.0
CACHE_PRIMING_INTERVAL_SECONDS = 3.0
CACHE_WARM_CONSECUTIVE_READS = 3
class CacheChatRequest(BaseModel):
"""OpenAI-format chat body whose content blocks carry ``cache_control``."""
model: str
messages: list[RichMessage]
max_tokens: int = 64
cache: dict[str, bool] = {"no-cache": True}
def _anthropic_params(model: str) -> LiteLLMParamsBody:
return LiteLLMParamsBody(model=model, api_key="os.environ/ANTHROPIC_API_KEY")
def _invoke_params(model: str, region: str) -> LiteLLMParamsBody:
return LiteLLMParamsBody(model=model, aws_region_name=region)
def _cacheable_system_turn(marker: str) -> RichMessage:
"""A system prompt comfortably above the 4096-token minimum cacheable size
of Haiku 4.5 (the smallest model here), unique per run so no other run's
cache entry can satisfy the read."""
text = " ".join(f"Reference paragraph {index} for run {marker}." for index in range(300))
return RichMessage(role="system", content=[TextBlock(text=text, cache_control=CacheControl())])
def _user_turn(text: str, *, cached: bool = False) -> RichMessage:
block = TextBlock(text=text, cache_control=CacheControl() if cached else None)
return RichMessage(role="user", content=[block])
def _assistant_turn(text: str) -> RichMessage:
return RichMessage(role="assistant", content=[TextBlock(text=text)])
def _system_reminder_turn() -> RichMessage:
return RichMessage(
role="system",
content=[TextBlock(text="<system-reminder>Answer with exactly one word.</system-reminder>")],
)
def _post_chat(client: PassthroughClient, key: str, body: CacheChatRequest) -> Result[ChatResponse]:
return client.proxy.transport.post(
"/v1/chat/completions",
headers=client.proxy.transport.bearer(key),
json=body,
response_type=ChatResponse,
)
def _register_deployment(client: PassthroughClient, resources: ResourceManager, params: LiteLLMParamsBody) -> str:
model = f"e2e-chat-midsys-{unique_marker()}"
model_id = client.proxy.create_model(model, params)
resources.defer(lambda: client.proxy.delete_model(model_id))
return model
def _first_turn_user_text(marker: str) -> str:
"""A first user turn heavy enough (hundreds of tokens) that losing its cache
entry is unambiguous in the usage numbers, unique per attempt so priming
retries never depend on the proxy's response cache behavior."""
notes = " ".join(f"Session note {index} for attempt {marker}." for index in range(100))
return f"Reply with one word.\n{notes}"
def _cache_read_tokens(usage: Usage | None) -> int:
"""Cache-read tokens however the chat usage reports them: the Anthropic-style
``cache_read_input_tokens`` litellm forwards, or the OpenAI-style
``prompt_tokens_details.cached_tokens`` it mirrors them into."""
if usage is None:
return 0
if usage.cache_read_input_tokens:
return usage.cache_read_input_tokens
if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens:
return usage.prompt_tokens_details.cached_tokens
return 0
def _cache_creation_tokens(usage: Usage | None) -> int:
if usage is None:
return 0
return usage.cache_creation_input_tokens or 0
def _response_text(response: ChatResponse) -> str:
return "".join(choice.message.content or "" for choice in response.choices if choice.message)
def _response_role(response: ChatResponse) -> str | None:
first = response.choices[0].message if response.choices else None
return first.role if first else None
class PrimedCache(BaseModel):
first_user_text: str
prefix_read_tokens: int
first_turn_creation_tokens: int
@property
def full_prefix_tokens(self) -> int:
return self.prefix_read_tokens + self.first_turn_creation_tokens
def _prime_prompt_cache(client: PassthroughClient, key: str, model: str, system_turn: RichMessage) -> PrimedCache:
"""Send first-turn calls (fresh cache-marked user turn each attempt,
identical system prefix) until one both reads the system prefix back from
cache and writes its own user-turn chunk, then re-send that exact turn until
its own chunk reads back on three sends in a row, proving the cache is live
in both directions before the reminder turn goes out (a freshly written entry
can take a few seconds to become readable). Only the pre-reminder turn is
ever retried here, so retries can never warm a mutated-prefix cache entry and
mask the regression the second turn asserts on."""
deadline = time.monotonic() + CACHE_PRIMING_DEADLINE_SECONDS
while True:
user_text = _first_turn_user_text(unique_marker())
body = CacheChatRequest(model=model, messages=[system_turn, _user_turn(user_text, cached=True)])
usage = unwrap(_post_chat(client, key, body)).usage
read_tokens = _cache_read_tokens(usage)
creation_tokens = _cache_creation_tokens(usage)
if read_tokens > 0 and creation_tokens > 0:
primed = PrimedCache(
first_user_text=user_text,
prefix_read_tokens=read_tokens,
first_turn_creation_tokens=creation_tokens,
)
if _first_turn_reads_back(client, key, body, primed.full_prefix_tokens, deadline):
return primed
if time.monotonic() >= deadline:
pytest.fail(
f"{model}: prompt cache never became readable in full within "
f"{CACHE_PRIMING_DEADLINE_SECONDS}s (last usage: {usage})"
)
time.sleep(CACHE_PRIMING_INTERVAL_SECONDS)
def _reads_full_prefix(client: PassthroughClient, key: str, body: CacheChatRequest, full_prefix_tokens: int) -> bool:
return _cache_read_tokens(unwrap(_post_chat(client, key, body)).usage) >= full_prefix_tokens
def _first_turn_reads_back(
client: PassthroughClient,
key: str,
body: CacheChatRequest,
full_prefix_tokens: int,
deadline: float,
) -> bool:
"""True once the full prefix reads back on CACHE_WARM_CONSECUTIVE_READS sends in
a row. Some providers' global endpoints serve the prompt cache per region, so a
fresh entry can be missing from the region the next request lands on; each miss
re-creates the entry there, so the streak converges as the regions warm up."""
while time.monotonic() < deadline:
if all(_reads_full_prefix(client, key, body, full_prefix_tokens) for _ in range(CACHE_WARM_CONSECUTIVE_READS)):
return True
time.sleep(CACHE_PRIMING_INTERVAL_SECONDS)
return False
def _reminder_turn_body(model: str, system_turn: RichMessage, primed: PrimedCache) -> CacheChatRequest:
"""Turn two in OpenAI shape: the primed prefix, an assistant reply, the
mid-conversation system reminder, and a fresh cache-marked user turn."""
return CacheChatRequest(
model=model,
messages=[
system_turn,
_user_turn(primed.first_user_text, cached=True),
_assistant_turn("OK."),
_system_reminder_turn(),
_user_turn("Reply with one word again.", cached=True),
],
)
def _assert_flagged_model_keeps_cache(
client: PassthroughClient, resources: ResourceManager, params: LiteLLMParamsBody
) -> None:
model = _register_deployment(client, resources, params)
key = resources.key(models=[model])
system_turn = _cacheable_system_turn(unique_marker())
primed = _prime_prompt_cache(client, key, model, system_turn)
second = unwrap(_post_chat(client, key, _reminder_turn_body(model, system_turn, primed)))
read_tokens = _cache_read_tokens(second.usage)
assert _response_role(second) == "assistant", f"{model}: unexpected role {_response_role(second)!r}"
assert _response_text(second).strip(), f"{model}: reminder turn returned no completion text"
assert read_tokens >= primed.full_prefix_tokens, (
f"{model}: turn with a mid-conversation system reminder read {read_tokens} "
f"cached tokens, expected at least the {primed.full_prefix_tokens} cached on "
f"turn one ({primed.prefix_read_tokens} system prefix + "
f"{primed.first_turn_creation_tokens} first user turn); the reminder was "
f"hoisted into the top-level system field, which mutates the cached prefix "
f"and re-bills the conversation at cache-write pricing"
)
def _assert_unflagged_model_converts_and_succeeds(
client: PassthroughClient, resources: ResourceManager, params: LiteLLMParamsBody
) -> None:
model = _register_deployment(client, resources, params)
key = resources.key(models=[model])
system_turn = _cacheable_system_turn(unique_marker())
primed = _prime_prompt_cache(client, key, model, system_turn)
second = unwrap(_post_chat(client, key, _reminder_turn_body(model, system_turn, primed)))
read_tokens = _cache_read_tokens(second.usage)
assert _response_role(second) == "assistant", f"{model}: unexpected role {_response_role(second)!r}"
assert _response_text(second).strip(), (
f"{model}: conversation with a mid-conversation system reminder returned "
f"no text; the reminder was forwarded in place to a model that rejects "
f"role 'system' inside messages instead of being converted to a user turn"
)
assert read_tokens >= primed.full_prefix_tokens, (
f"{model}: reminder turn read {read_tokens} cached tokens, expected at least "
f"the {primed.full_prefix_tokens} cached on turn one "
f"({primed.prefix_read_tokens} system prefix + "
f"{primed.first_turn_creation_tokens} first user turn); the reminder was "
f"hoisted into the top-level system field instead of being converted to a "
f"user turn in place, mutating the cached prefix and re-billing the "
f"conversation at cache-write pricing"
)
class TestAnthropicChatMidConversationSystem:
FLAGGED_MODEL = "anthropic/claude-opus-4-8"
UNFLAGGED_MODEL = "anthropic/claude-haiku-4-5-20251001"
@pytest.mark.covers(
"llm.chat_completions.anthropic.mid_conversation_system.nonstream.cache_hit",
exercised_on=[],
)
def test_flagged_model_keeps_prompt_cache_across_system_reminder(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
_assert_flagged_model_keeps_cache(client, resources, _anthropic_params(self.FLAGGED_MODEL))
@pytest.mark.covers(
"llm.chat_completions.anthropic.mid_conversation_system.nonstream.works",
exercised_on=[],
)
def test_unflagged_model_converts_system_reminder_and_succeeds(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
_assert_unflagged_model_converts_and_succeeds(client, resources, _anthropic_params(self.UNFLAGGED_MODEL))
class TestBedrockInvokeChatMidConversationSystem:
FLAGGED_MODEL = "bedrock/invoke/us.anthropic.claude-sonnet-5"
UNFLAGGED_MODEL = "bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0"
AWS_REGION = "us-east-1"
@pytest.mark.covers(
"llm.chat_completions.bedrock_invoke.mid_conversation_system.nonstream.cache_hit",
exercised_on=[],
)
def test_flagged_model_keeps_prompt_cache_across_system_reminder(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
_assert_flagged_model_keeps_cache(client, resources, _invoke_params(self.FLAGGED_MODEL, self.AWS_REGION))
@pytest.mark.covers(
"llm.chat_completions.bedrock_invoke.mid_conversation_system.nonstream.works",
exercised_on=[],
)
def test_unflagged_model_converts_system_reminder_and_succeeds(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
_assert_unflagged_model_converts_and_succeeds(
client, resources, _invoke_params(self.UNFLAGGED_MODEL, self.AWS_REGION)
)

View file

@ -3847,3 +3847,51 @@ def test_convert_to_anthropic_tool_invoke_keeps_paired_server_tool_use():
},
server_result,
]
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."}]}
def test_anthropic_messages_pt_drops_a_system_message_with_no_text():
"""Anthropic rejects empty text blocks, so a text-less system message must
vanish rather than reach the wire as an empty system turn."""
messages = [
{"role": "user", "content": "First question"},
{"role": "system", "content": ""},
{"role": "assistant", "content": "Yes"},
]
result = anthropic_messages_pt(messages=messages, model="claude-opus-4-8", llm_provider="anthropic")
assert [m["role"] for m in result] == ["user", "assistant"]

View file

@ -0,0 +1,392 @@
"""Placement policy for mid-conversation ``role: "system"`` messages on the chat path.
The provider-facing behaviour is covered through ``transform_request`` in the
Anthropic, Vertex, Azure AI and Bedrock Invoke transformation tests; these pin
the pure placement rules on the OpenAI-format message list.
"""
import pytest
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import encrypted_reasoning_signature
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
CONVERTED_SYSTEM_NOTE,
place_mid_conversation_system,
split_leading_system_run,
)
def _roles(messages: object) -> list[str]:
return [m["role"] if isinstance(m, dict) else m.role for m in messages]
def _texts(message: dict) -> list[str]:
return [block["text"] for block in message["content"]]
SENDS_NOTHING = pytest.mark.parametrize(
"empty_content",
[[], None, [{"type": "input_audio", "input_audio": {"data": "AAAA", "format": "wav"}}]],
ids=["empty-list", "none", "unsupported-part-only"],
)
def test_split_leading_system_run_keeps_later_system_messages_in_the_conversation():
messages = [
{"role": "system", "content": "one"},
{"role": "system", "content": "two"},
{"role": "user", "content": "q"},
{"role": "system", "content": "reminder"},
]
leading, later = split_leading_system_run(messages)
assert [m["content"] for m in leading] == ["one", "two"]
assert _roles(later) == ["user", "system"]
def test_flagged_placement_moves_a_system_run_after_the_user_turn_that_follows_it():
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "a1"},
{"role": "system", "content": "reminder"},
{"role": "user", "content": "q2"},
{"role": "assistant", "content": "a2"},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=True)
assert _roles(placed) == ["user", "assistant", "user", "system", "assistant"]
def test_flagged_placement_pushes_a_system_between_two_user_turns_after_both():
"""Two user turns collapse into one on the wire, and a system message must
be followed by an assistant turn or nothing."""
messages = [
{"role": "user", "content": "q1"},
{"role": "system", "content": "reminder"},
{"role": "user", "content": "q2"},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=True)
assert _roles(placed) == ["user", "user", "system"]
def test_flagged_placement_keeps_a_system_after_tool_results():
messages = [
{"role": "user", "content": "q1"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "c1", "content": "r"},
{"role": "system", "content": "reminder"},
{"role": "assistant", "content": "a2"},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=True)
assert _roles(placed) == ["user", "assistant", "tool", "system", "assistant"]
def test_flagged_placement_drops_a_system_message_with_no_text():
messages = [
{"role": "user", "content": "q1"},
{"role": "system", "content": ""},
{"role": "assistant", "content": "a1"},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=True)
assert _roles(placed) == ["user", "assistant"]
def test_placement_reads_roles_off_pydantic_messages_in_the_history():
"""Callers routinely append the previous ``litellm.Message`` object straight
into the history; placement must read its role without assuming a dict and
hand the object through untouched."""
assistant = litellm.Message(role="assistant", content="a1")
messages = [
{"role": "user", "content": "q1"},
assistant,
{"role": "system", "content": "reminder"},
{"role": "user", "content": "q2"},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=True)
assert _roles(placed) == ["user", "assistant", "user", "system"]
assert placed[1] is assistant
def test_unflagged_conversion_keeps_the_client_order_when_no_tool_result_follows():
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "a1"},
{"role": "system", "content": "reminder"},
{"role": "user", "content": "q2"},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=False)
assert _roles(placed) == ["user", "assistant", "user", "user"]
assert _texts(placed[2]) == [CONVERTED_SYSTEM_NOTE, "reminder"]
@pytest.mark.parametrize(
"cache_control, expected",
[
({"type": "ephemeral", "ttl": "1h"}, {"type": "ephemeral", "ttl": "1h"}),
({"type": "ephemeral", "ttl": "5m"}, {"type": "ephemeral", "ttl": "5m"}),
({"type": "ephemeral", "ttl": "2h"}, {"type": "ephemeral"}),
],
)
def test_unflagged_conversion_rebuilds_cache_control_on_the_converted_block(cache_control, expected):
"""Only the shapes Anthropic accepts survive: ephemeral with a 5m or 1h ttl, or no ttl."""
messages = [
{"role": "user", "content": "q1"},
{"role": "system", "content": "reminder", "cache_control": cache_control},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=False)
assert placed[1]["content"][1] == {"type": "text", "text": "reminder", "cache_control": expected}
def test_unflagged_conversion_drops_a_cache_control_that_is_not_ephemeral():
messages = [
{"role": "user", "content": "q1"},
{"role": "system", "content": "reminder", "cache_control": {"type": "persistent"}},
]
placed = place_mid_conversation_system(messages, supports_mid_conversation_system=False)
assert placed[1]["content"][1] == {"type": "text", "text": "reminder"}
def test_placement_is_a_no_op_without_later_system_messages():
messages = [{"role": "user", "content": "q1"}, {"role": "assistant", "content": "a1"}]
assert place_mid_conversation_system(messages, supports_mid_conversation_system=False) == tuple(messages)
assert place_mid_conversation_system(messages, supports_mid_conversation_system=True) == tuple(messages)
def test_flagged_placement_converts_a_run_followed_by_an_assistant_turn_in_place():
placed = place_mid_conversation_system(
[
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "a1"},
{"role": "system", "content": "reminder"},
{"role": "assistant", "content": "a2"},
{"role": "user", "content": "q2"},
],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "assistant", "user", "assistant", "user"]
assert _texts(placed[2]) == [CONVERTED_SYSTEM_NOTE, "reminder"]
def test_flagged_placement_of_an_earlier_run_does_not_move_when_later_turns_are_appended():
turn_n = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "a1"},
{"role": "system", "content": "reminder"},
]
turn_n_plus_one = [
*turn_n,
{"role": "assistant", "content": "a2"},
{"role": "user", "content": "q2"},
]
placed_n = place_mid_conversation_system(turn_n, supports_mid_conversation_system=True)
placed_n_plus_one = place_mid_conversation_system(turn_n_plus_one, supports_mid_conversation_system=True)
assert placed_n_plus_one[: len(placed_n)] == placed_n
assert _roles(placed_n_plus_one) == ["user", "assistant", "user", "assistant", "user"]
@SENDS_NOTHING
def test_flagged_placement_converts_a_run_whose_preceding_user_turn_sends_nothing(empty_content):
placed = place_mid_conversation_system(
[
{"role": "user", "content": empty_content},
{"role": "system", "content": "reminder"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "q2"},
],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "user", "assistant", "user"]
assert _texts(placed[1]) == [CONVERTED_SYSTEM_NOTE, "reminder"]
@SENDS_NOTHING
def test_flagged_placement_converts_a_run_whose_following_user_turn_sends_nothing(empty_content):
placed = place_mid_conversation_system(
[
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "a1"},
{"role": "system", "content": "reminder"},
{"role": "user", "content": empty_content},
],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "assistant", "user", "user"]
assert _texts(placed[2]) == [CONVERTED_SYSTEM_NOTE, "reminder"]
def test_flagged_placement_keeps_a_system_behind_a_user_turn_merged_with_an_empty_one():
placed = place_mid_conversation_system(
[
{"role": "user", "content": "q1"},
{"role": "user", "content": []},
{"role": "system", "content": "reminder"},
{"role": "assistant", "content": "a1"},
],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "user", "system", "assistant"]
EMPTY_ASSISTANT = pytest.mark.parametrize(
"empty_assistant",
[
{"role": "assistant", "content": None},
{"role": "assistant", "content": []},
{"role": "assistant", "content": [{"type": "thinking", "thinking": "unsigned"}]},
{
"role": "assistant",
"content": [{"type": "thinking", "thinking": "bridged", "signature": encrypted_reasoning_signature("abc")}],
},
{
"role": "assistant",
"content": None,
"thinking_blocks": [{"type": "redacted_thinking", "data": encrypted_reasoning_signature("abc")}],
},
{
"role": "assistant",
"content": [{"type": "thinking", "thinking": "unsigned"}],
"thinking_blocks": [{"type": "thinking", "thinking": "hm", "signature": "s"}],
},
{
"role": "assistant",
"content": [{"type": "redacted_thinking", "data": "x"}],
"thinking_blocks": [{"type": "redacted_thinking", "data": "x"}],
},
litellm.Message(role="assistant", content=None),
],
ids=[
"none",
"empty-list",
"unsigned-thinking-part",
"encrypted-thinking-part",
"encrypted-redacted-thinking-block",
"unsigned-inline-part-hides-signed-block",
"inline-redacted-part-hides-redacted-block",
"pydantic-none",
],
)
@EMPTY_ASSISTANT
def test_flagged_placement_converts_a_run_when_the_assistant_turn_after_its_anchor_sends_nothing(empty_assistant):
placed = place_mid_conversation_system(
[
{"role": "user", "content": "q1"},
{"role": "system", "content": "reminder"},
empty_assistant,
{"role": "user", "content": "q2"},
],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "user", "assistant", "user"]
assert _texts(placed[1]) == [CONVERTED_SYSTEM_NOTE, "reminder"]
assert placed[2] is empty_assistant
@EMPTY_ASSISTANT
def test_flagged_placement_keeps_a_system_whose_empty_assistant_follower_ends_the_array(empty_assistant):
placed = place_mid_conversation_system(
[{"role": "user", "content": "q1"}, {"role": "system", "content": "reminder"}, empty_assistant],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "system", "assistant"]
@pytest.mark.parametrize(
"assistant_turn",
[
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "toolu_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}],
},
{
"role": "assistant",
"content": None,
"thinking_blocks": [{"type": "thinking", "thinking": "hm", "signature": "s"}],
},
{"role": "assistant", "content": None, "thinking_blocks": [{"type": "redacted_thinking", "data": "x"}]},
{"role": "assistant", "content": [{"type": "thinking", "thinking": "hm", "signature": "s"}]},
{"role": "assistant", "content": None, "function_call": {"name": "f", "arguments": "{}"}},
litellm.Message(
role="assistant",
content="",
tool_calls=[{"id": "toolu_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}],
),
],
ids=[
"tool-calls",
"signed-thinking-block",
"redacted-thinking-block",
"signed-thinking-part",
"function-call",
"pydantic-tool-calls",
],
)
def test_flagged_placement_keeps_a_system_before_an_assistant_turn_that_renders_without_text(assistant_turn):
placed = place_mid_conversation_system(
[
{"role": "user", "content": "q1"},
{"role": "system", "content": "reminder"},
assistant_turn,
{"role": "user", "content": "q2"},
],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "system", "assistant", "user"]
@pytest.mark.parametrize(
"padded_assistant",
[
{"role": "assistant", "content": ""},
{"role": "assistant", "content": " "},
{"role": "assistant", "content": [{"type": "text", "text": ""}]},
litellm.Message(role="assistant", content=""),
],
ids=["empty-string", "whitespace-string", "empty-text-part", "pydantic-empty-string"],
)
def test_flagged_placement_keeps_a_system_before_an_assistant_turn_whose_empty_text_the_converter_pads(
padded_assistant,
):
placed = place_mid_conversation_system(
[
{"role": "user", "content": "q1"},
{"role": "system", "content": "reminder"},
padded_assistant,
{"role": "user", "content": "q2"},
],
supports_mid_conversation_system=True,
)
assert _roles(placed) == ["user", "system", "assistant", "user"]

View file

@ -1,5 +1,6 @@
import asyncio
import contextlib
import copy
import datetime
import json
import logging
@ -8781,3 +8782,41 @@ def test_extract_response_obj_and_hidden_params_reads_binary_content_hidden_para
assert hidden_params == {"headers": {"x-request-id": "req_tts"}}
assert response_obj["object"] == "binary"
def _preserved_thinking_client_turns() -> tuple[list[dict], list[dict]]:
turn_n = [{"role": "user", "content": "First question"}]
reply = {
"role": "assistant",
"content": "First answer",
"thinking_blocks": [{"type": "thinking", "thinking": "Working it out.", "signature": "sig-1"}],
}
return turn_n, [*turn_n, reply, {"role": "user", "content": "Second question"}]
@pytest.mark.asyncio
async def test_prompt_management_with_unchanged_variables_replays_a_byte_identical_prefix(logging_obj, tmp_path):
"""A prompt template rendered with the same variables on every turn must prepend the
same messages, or the signed thinking blocks in the history lose their binding."""
from litellm.integrations.dotprompt.dotprompt_manager import DotpromptManager
(tmp_path / "greeting.prompt").write_text(
"---\nmodel: claude-fable-5-1\n---\nSystem: You are a {{persona}}. Answer in one sentence.\n"
)
manager = DotpromptManager(prompt_directory=str(tmp_path))
compiled = [
await logging_obj.async_get_chat_completion_prompt(
model="claude-fable-5-1",
messages=copy.deepcopy(turn),
non_default_params={},
prompt_variables={"persona": "pirate"},
prompt_id="greeting",
prompt_management_logger=manager,
)
for turn in _preserved_thinking_client_turns()
]
(_, messages_n, _), (_, messages_n_plus_one, _) = compiled
assert json.dumps(messages_n_plus_one[: len(messages_n)], sort_keys=True) == json.dumps(messages_n, sort_keys=True)
assert messages_n[0] == {"role": "system", "content": "You are a pirate. Answer in one sentence."}
assert len(messages_n_plus_one) == len(messages_n) + 2

View file

@ -437,3 +437,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() -> list[dict]:
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

@ -1,3 +1,4 @@
import copy
import json
import os
from typing import Final
@ -8,6 +9,7 @@ import pytest
import litellm
from litellm import ModelResponse
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import CONVERTED_SYSTEM_NOTE
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.types.llms.bedrock import ConverseTokenUsageBlock
@ -7584,6 +7586,246 @@ def test_eager_input_streaming_non_boolean_is_a_bad_request():
)
def test_mid_conversation_system_after_multiple_tool_results():
config = AmazonConverseConfig()
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "calling tools",
"tool_calls": [
{
"id": "call_a",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
},
{"role": "system", "content": "reminder"},
{"role": "tool", "tool_call_id": "call_a", "content": "r1"},
{"role": "tool", "tool_call_id": "call_b", "content": "r2"},
{"role": "user", "content": "done"},
]
out_messages, system_blocks = config._transform_system_message(messages)
assert system_blocks == []
assert [m["role"] for m in out_messages] == [
"user",
"assistant",
"tool",
"tool",
"user",
"user",
]
assert out_messages[2]["content"] == "r1"
assert out_messages[3]["content"] == "r2"
# Reminder lands after ALL tool results, not between them.
assert out_messages[4]["content"][1]["text"] == "reminder"
assert out_messages[5]["content"] == "done"
def test_mid_conversation_system_reorders_around_a_pydantic_assistant_tool_call():
config = AmazonConverseConfig()
assistant = litellm.Message(
role="assistant",
content="calling tools",
tool_calls=[{"id": "call_a", "type": "function", "function": {"name": "f", "arguments": "{}"}}],
)
messages = [
{"role": "user", "content": "hi"},
assistant,
{"role": "system", "content": "reminder"},
{"role": "tool", "tool_call_id": "call_a", "content": "r1"},
{"role": "user", "content": "done"},
]
out_messages, system_blocks = config._transform_system_message(messages)
assert system_blocks == []
assert [m["role"] for m in out_messages] == ["user", "assistant", "tool", "user", "user"]
assert out_messages[1] is assistant
assert out_messages[3]["content"][1]["text"] == "reminder"
def test_mid_conversation_multi_system_run_after_multiple_tool_results():
config = AmazonConverseConfig()
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "calling tools",
"tool_calls": [
{
"id": "call_a",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
},
{"role": "system", "content": "reminder 1"},
{"role": "system", "content": "reminder 2"},
{"role": "tool", "tool_call_id": "call_a", "content": "r1"},
{"role": "tool", "tool_call_id": "call_b", "content": "r2"},
{"role": "user", "content": "done"},
]
out_messages, _ = config._transform_system_message(messages)
assert [m["role"] for m in out_messages] == [
"user",
"assistant",
"tool",
"tool",
"user",
"user",
"user",
]
assert out_messages[4]["content"][1]["text"] == "reminder 1"
assert out_messages[5]["content"][1]["text"] == "reminder 2"
def test_opens_with_tool_result_rejects_non_dict():
config = AmazonConverseConfig()
assert config._opens_with_tool_result("not-a-dict") is False
assert config._opens_with_tool_result(None) is False
assert config._opens_with_tool_result([{"role": "tool"}]) is False
def test_mid_conversation_system_without_tools_stays_in_place():
config = AmazonConverseConfig()
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "system", "content": "reminder"},
{"role": "user", "content": "thanks"},
]
out_messages, system_blocks = config._transform_system_message(messages)
assert [b["text"] for b in system_blocks if "text" in b] == ["You are helpful."]
assert [m["role"] for m in out_messages] == ["user", "assistant", "user", "user"]
assert out_messages[2]["content"][1]["text"] == "reminder"
assert out_messages[3]["content"] == "thanks"
def test_mid_conversation_system_str_with_cache_control():
config = AmazonConverseConfig()
messages = [
{"role": "user", "content": "hi"},
{
"role": "system",
"content": "reminder",
"cache_control": {"type": "ephemeral"},
},
{"role": "user", "content": "done"},
]
out_messages, system_blocks = config._transform_system_message(messages)
assert system_blocks == []
assert out_messages[1]["role"] == "user"
assert out_messages[1]["content"][1] == {
"type": "text",
"text": "reminder",
"cache_control": {"type": "ephemeral"},
}
def test_mid_conversation_system_list_content_with_cache_control():
config = AmazonConverseConfig()
messages = [
{"role": "user", "content": "hi"},
{
"role": "system",
"content": [
{"type": "text", "text": "keep this", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "plain"},
{"type": "text", "text": ""},
{"type": "image", "source": "x"},
"raw-string",
],
},
{"role": "user", "content": "done"},
]
out_messages, system_blocks = config._transform_system_message(messages)
assert system_blocks == []
blocks = out_messages[1]["content"]
assert blocks[0]["text"] == CONVERTED_SYSTEM_NOTE
assert blocks[1] == {
"type": "text",
"text": "keep this",
"cache_control": {"type": "ephemeral"},
}
assert blocks[2] == {"type": "text", "text": "plain"}
assert len(blocks) == 3
@pytest.mark.parametrize(
"empty_content",
["", [], None, [{"type": "image", "source": "x"}, {"type": "text", "text": ""}]],
ids=["empty-string", "empty-list", "none", "no-text-parts"],
)
def test_mid_conversation_system_entry_without_text_is_dropped(empty_content):
config = AmazonConverseConfig()
messages = [
{"role": "user", "content": "hi"},
{"role": "system", "content": empty_content},
{"role": "user", "content": "done"},
]
out_messages, system_blocks = config._transform_system_message(messages)
assert system_blocks == []
assert out_messages == [{"role": "user", "content": "hi"}, {"role": "user", "content": "done"}]
def _thinking_reply(text: str) -> dict:
return {
"role": "assistant",
"content": text,
"thinking_blocks": [{"type": "thinking", "thinking": "Working it out.", "signature": f"sig-{text}"}],
}
def _preserved_thinking_turns(reminder_after_user: bool) -> tuple[list[dict], list[dict], list[dict]]:
turn_n = [{"role": "system", "content": "You are terse."}, {"role": "user", "content": "First question"}]
reminder = {"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"}
second_question = {"role": "user", "content": "Second question"}
second_turn = [second_question, reminder] if reminder_after_user else [reminder, second_question]
turn_n_plus_one = [*turn_n, _thinking_reply("First answer"), *second_turn]
turn_n_plus_two = [*turn_n_plus_one, _thinking_reply("Second answer"), {"role": "user", "content": "Third question"}]
return turn_n, turn_n_plus_one, turn_n_plus_two
def _replayed_prefix(request: dict, message_count: int) -> str:
replayed = {
"system": request.get("system"),
"toolConfig": request.get("toolConfig"),
"messages": request["messages"][:message_count],
}
return json.dumps(replayed, sort_keys=True)
def _assert_prefix_stable(requests: list[dict]) -> None:
for earlier, later in zip(requests, requests[1:]):
count = len(earlier["messages"])
assert _replayed_prefix(later, count) == _replayed_prefix(earlier, count)
@pytest.mark.parametrize("reminder_after_user", [True, False])
def test_flagged_model_replays_a_byte_identical_prefix_around_a_mid_conversation_reminder(
local_model_cost_map, reminder_after_user
):
"""Converse rejects ``role: system`` inside ``messages``, so the reminder becomes a
user turn in place; hoisting it into ``system`` would change the prefix every
signed thinking block in the history is bound to."""
requests = [
AmazonConverseConfig().transform_request(
model="bedrock/us.anthropic.claude-fable-5-1",
messages=copy.deepcopy(turn),
optional_params={},
litellm_params={},
headers={},
)
for turn in _preserved_thinking_turns(reminder_after_user)
]
_assert_prefix_stable(requests)
assert requests[1]["system"] == [{"text": "You are terse."}]
assert [m["role"] for m in requests[1]["messages"]] == ["user", "assistant", "user"]
assert [m["role"] for m in requests[2]["messages"]] == ["user", "assistant", "user", "assistant", "user"]
@pytest.mark.parametrize("model", ("anthropic.claude-opus-4-7", "us.anthropic.claude-opus-4-7"))
def test_converse_accepts_anthropic_default_temperature(model: str) -> None:
result: Final = litellm.utils.get_optional_params(

View file

@ -1,4 +1,7 @@
import copy
import json
import pytest
from litellm.anthropic_beta_headers_manager import (
@ -771,3 +774,104 @@ def test_vertex_ai_anthropic_tool_based_response_format_still_upgrades_legacy_th
assert "tools" in result_params
assert result_params["thinking"] == {"type": "adaptive"}
assert result_params["output_config"] == {"effort": "high"}
def _mid_conversation_system_conversation() -> list[dict]:
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>"}],
}
def _thinking_reply(text: str) -> dict:
return {
"role": "assistant",
"content": text,
"thinking_blocks": [{"type": "thinking", "thinking": "Working it out.", "signature": f"sig-{text}"}],
}
def _preserved_thinking_turns(reminder_after_user: bool) -> tuple[list[dict], list[dict], list[dict]]:
turn_n = [{"role": "system", "content": "You are terse."}, {"role": "user", "content": "First question"}]
reminder = {"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"}
second_question = {"role": "user", "content": "Second question"}
second_turn = [second_question, reminder] if reminder_after_user else [reminder, second_question]
turn_n_plus_one = [*turn_n, _thinking_reply("First answer"), *second_turn]
turn_n_plus_two = [*turn_n_plus_one, _thinking_reply("Second answer"), {"role": "user", "content": "Third question"}]
return turn_n, turn_n_plus_one, turn_n_plus_two
def _replayed_prefix(request: dict, message_count: int) -> str:
replayed = {
"system": request.get("system"),
"tools": request.get("tools"),
"messages": request["messages"][:message_count],
}
return json.dumps(replayed, sort_keys=True)
def _assert_prefix_stable(requests: list[dict]) -> None:
for earlier, later in zip(requests, requests[1:]):
count = len(earlier["messages"])
assert _replayed_prefix(later, count) == _replayed_prefix(earlier, count)
@pytest.mark.parametrize("reminder_after_user", [True, False])
def test_chat_flagged_model_replays_a_byte_identical_prefix_around_a_mid_conversation_reminder(
local_model_cost_map, reminder_after_user
):
"""Preserved thinking binds each signed block to the request prefix it was created
under (``system``, ``tools`` and the earlier messages), so turn N's transformed
request must be a byte-identical prefix of turn N+1's or the block is dropped."""
requests = [
VertexAIAnthropicConfig().transform_request(
model="claude-fable-5-1", messages=copy.deepcopy(turn), optional_params={}, litellm_params={}, headers={}
)
for turn in _preserved_thinking_turns(reminder_after_user)
]
_assert_prefix_stable(requests)
assert [m["role"] for m in requests[1]["messages"]] == ["user", "assistant", "user", "system"]
assert [m["role"] for m in requests[2]["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]

View file

@ -4,10 +4,15 @@ Tests PII detection and masking for different message formats
"""
import asyncio
import copy
import json
import re
from contextlib import asynccontextmanager
from typing import Final
from unittest.mock import MagicMock, patch
from aiohttp import web
from aiohttp.test_utils import TestServer
import pytest
@ -3849,3 +3854,79 @@ async def test_chunk_fanout_bound_is_shared_across_concurrent_calls():
)
assert state["peak"] >= 2
assert state["peak"] <= PRESIDIO_ANALYZE_CHUNK_CONCURRENCY
_PERSON_NAME: Final = re.compile(r"\b[A-Z][a-z]+ [A-Z][a-z]+\b")
def _person_spans(text: str) -> list[dict]:
return [
{"entity_type": "PERSON", "start": match.start(), "end": match.end(), "score": 0.85, "analysis_explanation": None}
for match in _PERSON_NAME.finditer(text)
]
def _redacted(text: str, spans: list[dict]) -> str:
starts = [0, *(span["end"] for span in spans)]
ends = [*(span["start"] for span in spans), len(text)]
return "<PERSON>".join(text[start:end] for start, end in zip(starts, ends))
async def _fake_analyze(request: web.Request) -> web.Response:
payload = await request.json()
return web.json_response(_person_spans(payload["text"]))
async def _fake_anonymize(request: web.Request) -> web.Response:
payload = await request.json()
spans = payload["analyzer_results"]
items = [{"entity_type": span["entity_type"], "operator": "replace"} for span in spans]
return web.json_response({"text": _redacted(payload["text"], spans), "items": items})
def _fake_presidio_app() -> web.Application:
app = web.Application()
app.router.add_post("/analyze", _fake_analyze)
app.router.add_post("/anonymize", _fake_anonymize)
return app
def _pii_turns() -> tuple[list[dict], list[dict]]:
turn_n = [
{"role": "system", "content": "You are terse."},
{"role": "user", "content": "My name is John Smith and my colleague is Alice Brown."},
]
reply = {
"role": "assistant",
"content": "Noted.",
"thinking_blocks": [{"type": "thinking", "thinking": "Working it out.", "signature": "sig-1"}],
}
return turn_n, [*turn_n, reply, {"role": "user", "content": "Now compare against Bob Jones too."}]
async def test_pii_masking_replays_a_byte_identical_prefix_across_turns(mock_user_api_key, mock_cache):
"""Masking rewrites the history on every turn, so the rewrite of an earlier message
must not depend on the turns that came after it or the signed thinking blocks in
the history lose their binding. The analyzer and anonymizer are an in-process fake
handed to the guardrail through its api_base settings."""
async with TestServer(_fake_presidio_app()) as server:
guardrail = _OPTIONAL_PresidioPIIMasking(
presidio_analyzer_api_base=str(server.make_url("/")),
presidio_anonymizer_api_base=str(server.make_url("/")),
pii_entities_config={PiiEntityType.PERSON: PiiAction.MASK},
)
masked = [
await guardrail.async_pre_call_hook(
user_api_key_dict=mock_user_api_key,
cache=mock_cache,
data={"model": "claude-fable-5-1", "messages": copy.deepcopy(turn)},
call_type="completion",
)
for turn in _pii_turns()
]
await guardrail._close_http_session()
earlier, later = (result["messages"] for result in masked)
assert json.dumps(later[: len(earlier)], sort_keys=True) == json.dumps(earlier, sort_keys=True)
assert earlier[1]["content"] == "My name is <PERSON> and my colleague is <PERSON>."
assert later[3]["content"] == "Now compare against <PERSON> too."

View file

@ -1,5 +1,6 @@
import asyncio
import base64
import copy
import json
import uuid
from types import SimpleNamespace
@ -1010,3 +1011,102 @@ def test_bedrock_chat_invoke_eager_input_streaming_beta_not_duplicated_with_clie
)
assert result["anthropic_beta"] == [FINE_GRAINED_TOOL_STREAMING_BETA]
def _mid_conversation_system_conversation() -> list[dict]:
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>"}],
}
def _thinking_reply(text: str) -> dict:
return {
"role": "assistant",
"content": text,
"thinking_blocks": [{"type": "thinking", "thinking": "Working it out.", "signature": f"sig-{text}"}],
}
def _preserved_thinking_turns(reminder_after_user: bool) -> tuple[list[dict], list[dict], list[dict]]:
turn_n = [{"role": "system", "content": "You are terse."}, {"role": "user", "content": "First question"}]
reminder = {"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"}
second_question = {"role": "user", "content": "Second question"}
second_turn = [second_question, reminder] if reminder_after_user else [reminder, second_question]
turn_n_plus_one = [*turn_n, _thinking_reply("First answer"), *second_turn]
turn_n_plus_two = [*turn_n_plus_one, _thinking_reply("Second answer"), {"role": "user", "content": "Third question"}]
return turn_n, turn_n_plus_one, turn_n_plus_two
def _replayed_prefix(request: dict, message_count: int) -> str:
replayed = {
"system": request.get("system"),
"tools": request.get("tools"),
"messages": request["messages"][:message_count],
}
return json.dumps(replayed, sort_keys=True)
def _assert_prefix_stable(requests: list[dict]) -> None:
for earlier, later in zip(requests, requests[1:]):
count = len(earlier["messages"])
assert _replayed_prefix(later, count) == _replayed_prefix(earlier, count)
@pytest.mark.parametrize("reminder_after_user", [True, False])
def test_chat_flagged_model_replays_a_byte_identical_prefix_around_a_mid_conversation_reminder(
local_model_cost_map, reminder_after_user
):
"""Preserved thinking binds each signed block to the request prefix it was created
under (``system``, ``tools`` and the earlier messages), so turn N's transformed
request must be a byte-identical prefix of turn N+1's or the block is dropped."""
requests = [
AmazonAnthropicClaudeConfig().transform_request(
model="invoke/us.anthropic.claude-fable-5-1", messages=copy.deepcopy(turn), optional_params={}, litellm_params={}, headers={}
)
for turn in _preserved_thinking_turns(reminder_after_user)
]
_assert_prefix_stable(requests)
assert [m["role"] for m in requests[1]["messages"]] == ["user", "assistant", "user", "system"]
assert [m["role"] for m in requests[2]["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]