mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
fix(anthropic): keep mid-conversation system out of the chat completions system prompt
translate_system_message hoisted every role=system message, at any index, into the top-level system block. On a conversation carrying a mid-session reminder that rewrites the cached prefix, so the provider re-bills the whole history at cache-write pricing on every turn (#36559). #36968 fixed this on /v1/messages; the chat completions path, shared by first-party Anthropic, Vertex, Azure AI and Bedrock Invoke, still hoisted. Only the leading system run becomes the system prompt now. Later system messages go through the placement policy, and anthropic_messages_pt emits a system message instead of rejecting the role. The caller's message list is no longer mutated. Tests pin the two-turn prefix invariant across all four chat configs and both flag states.
This commit is contained in:
parent
dedd37460c
commit
986a505131
8 changed files with 432 additions and 7 deletions
|
|
@ -18,6 +18,7 @@ from litellm import verbose_logger
|
|||
from litellm._uuid import uuid
|
||||
from litellm.constants import REDACTED_BY_LITELLM
|
||||
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
|
||||
from litellm.llms.anthropic.mid_conversation_system import anthropic_system_messages
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client
|
||||
from litellm.types.files import get_file_extension_from_mime_type
|
||||
from litellm.types.llms.anthropic import *
|
||||
|
|
@ -2305,14 +2306,20 @@ def _drop_unsignable_thinking_blocks(
|
|||
return [block for block in thinking_blocks if not _is_unsignable_thinking_block(block)]
|
||||
|
||||
|
||||
# mutable-ok: anthropic_messages_pt's callers have always appended to the list it returns
|
||||
_AnthropicMessageList = list[AllAnthropicPassThroughMessageValues]
|
||||
|
||||
|
||||
def anthropic_messages_pt(
|
||||
messages: list[AllMessageValues],
|
||||
model: str,
|
||||
llm_provider: str,
|
||||
) -> list[AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam]:
|
||||
) -> _AnthropicMessageList:
|
||||
"""
|
||||
format messages for anthropic
|
||||
1. Anthropic supports roles like "user" and "assistant" (system prompt sent separately)
|
||||
1. Anthropic supports roles like "user" and "assistant" (system prompt sent separately).
|
||||
Models flagged ``supports_mid_conversation_system`` also accept "system" inside
|
||||
messages after a user turn; the caller decides placement, this keeps such messages.
|
||||
2. The first message always needs to be of role "user"
|
||||
3. Each message must alternate between "user" and "assistant" (this is not addressed as now by litellm)
|
||||
4. final assistant content cannot end with trailing whitespace (anthropic raises an error otherwise)
|
||||
|
|
@ -2337,7 +2344,7 @@ def anthropic_messages_pt(
|
|||
# add role=tool support to allow function call result/error submission
|
||||
user_message_types: Final = {"user", "tool", "function"}
|
||||
# reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, merge them.
|
||||
new_messages: Final[list[AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam]] = []
|
||||
new_messages: Final[_AnthropicMessageList] = [] # mutable-ok: accumulator behind the mutable return contract
|
||||
|
||||
if len(messages) == 0:
|
||||
if not litellm.modify_params:
|
||||
|
|
@ -2730,6 +2737,11 @@ def anthropic_messages_pt(
|
|||
if assistant_content:
|
||||
new_messages.append({"role": "assistant", "content": assistant_content})
|
||||
|
||||
## MID-CONVERSATION SYSTEM MESSAGES (placement is the caller's job) ##
|
||||
while msg_i < len(messages) and messages[msg_i]["role"] == "system":
|
||||
new_messages.extend(anthropic_system_messages(messages[msg_i]))
|
||||
msg_i += 1
|
||||
|
||||
if msg_i == init_msg_i: # prevent infinite loops
|
||||
raise litellm.BadRequestError(
|
||||
message=BAD_MESSAGE_ERROR_STR + f"passed in {messages[msg_i]}",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ from litellm.types.utils import Message as LitellmMessage
|
|||
from litellm.utils import (
|
||||
ModelResponse,
|
||||
Usage,
|
||||
_supports_factory,
|
||||
add_dummy_tool,
|
||||
any_assistant_message_has_thinking_blocks,
|
||||
get_max_tokens,
|
||||
|
|
@ -90,6 +91,7 @@ from ..common_utils import (
|
|||
process_anthropic_headers,
|
||||
strip_advisor_blocks_from_messages,
|
||||
)
|
||||
from ..mid_conversation_system import place_mid_conversation_system, split_leading_system_run
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
|
@ -1897,16 +1899,29 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
if _name_reverse_map and isinstance(litellm_params, dict):
|
||||
litellm_params[ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY] = _name_reverse_map
|
||||
|
||||
# Separate system prompt from rest of message
|
||||
anthropic_system_message_list: Final = self.translate_system_message(messages=messages)
|
||||
# Only the leading system run becomes the top-level system prompt. A later
|
||||
# system message stays in the conversation: hoisting it rewrites the cached
|
||||
# prefix and re-bills the whole history at cache-write pricing (#36559).
|
||||
leading_system_run, later_messages = split_leading_system_run(messages)
|
||||
anthropic_system_message_list: Final = self.translate_system_message(
|
||||
messages=list(leading_system_run) # mutable-ok: translate_system_message pops from the list it is given
|
||||
)
|
||||
# Handling anthropic API Prompt Caching
|
||||
if len(anthropic_system_message_list) > 0:
|
||||
optional_params["system"] = anthropic_system_message_list
|
||||
conversation: Final = place_mid_conversation_system(
|
||||
later_messages,
|
||||
supports_mid_conversation_system=_supports_factory(
|
||||
model=model,
|
||||
custom_llm_provider=self.custom_llm_provider,
|
||||
key="supports_mid_conversation_system",
|
||||
),
|
||||
)
|
||||
# Format rest of message according to anthropic guidelines
|
||||
try:
|
||||
anthropic_messages = anthropic_messages_pt(
|
||||
model=model,
|
||||
messages=messages,
|
||||
messages=list(conversation), # mutable-ok: anthropic_messages_pt rewrites entries in place
|
||||
llm_provider=self._resolved_provider,
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -361,7 +361,8 @@ class AnthropicMessagesSystemMessageParam(TypedDict, total=False):
|
|||
|
||||
AllAnthropicMessageValues = AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam
|
||||
|
||||
# System is not a native Anthropic message role; only pass-through adapters use this union.
|
||||
# role=system inside messages is accepted after a user turn on models flagged
|
||||
# supports_mid_conversation_system; pass-through adapters and the chat translator both emit it.
|
||||
AllAnthropicPassThroughMessageValues: TypeAlias = (
|
||||
AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam | AnthropicMessagesSystemMessageParam
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3578,3 +3578,38 @@ async def test_bedrock_converse_pdf_only_user_message_gets_text_block_async():
|
|||
assert len(result) == 1
|
||||
assert any("document" in block for block in result[0]["content"])
|
||||
assert _text_blocks(result[0]) == [BEDROCK_DOCUMENT_PLACEHOLDER_TEXT]
|
||||
|
||||
|
||||
def test_anthropic_messages_pt_keeps_system_role_after_user_turn():
|
||||
"""Models flagged supports_mid_conversation_system accept role=system inside
|
||||
messages; the converter must emit it as a system message with its text
|
||||
blocks and cache_control intact instead of rejecting the role."""
|
||||
messages = [
|
||||
{"role": "user", "content": "First question"},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Answer in one word.", "cache_control": {"type": "ephemeral"}}],
|
||||
},
|
||||
{"role": "assistant", "content": "Yes"},
|
||||
{"role": "user", "content": "Second question"},
|
||||
]
|
||||
|
||||
result = anthropic_messages_pt(messages=messages, model="claude-opus-4-8", llm_provider="anthropic")
|
||||
|
||||
assert [m["role"] for m in result] == ["user", "system", "assistant", "user"]
|
||||
assert result[1] == {
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Answer in one word.", "cache_control": {"type": "ephemeral"}}],
|
||||
}
|
||||
|
||||
|
||||
def test_anthropic_messages_pt_system_string_content_becomes_text_block():
|
||||
messages = [
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "system", "content": "Answer in one word."},
|
||||
]
|
||||
|
||||
result = anthropic_messages_pt(messages=messages, model="claude-opus-4-8", llm_provider="anthropic")
|
||||
|
||||
assert result[1] == {"role": "system", "content": [{"type": "text", "text": "Answer in one word."}]}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
import copy
|
||||
import pytest
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
|
@ -17,6 +18,13 @@ from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
|||
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
|
||||
AnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.llms.azure_ai.anthropic.transformation import AzureAnthropicConfig
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import (
|
||||
AmazonAnthropicClaudeConfig,
|
||||
)
|
||||
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import (
|
||||
VertexAIAnthropicConfig,
|
||||
)
|
||||
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
|
||||
from litellm.types.utils import ServerToolUse, Usage
|
||||
|
||||
|
|
@ -6207,3 +6215,213 @@ def test_disabled_thinking_omitted_only_for_always_on_models(
|
|||
assert "thinking" not in request
|
||||
else:
|
||||
assert request["thinking"] == {"type": "disabled"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mid-conversation ``role: "system"`` on the chat completions path.
|
||||
#
|
||||
# Hoisting a later system message into the top-level ``system`` block rewrites
|
||||
# the cached prefix and re-bills the whole conversation at cache-write pricing
|
||||
# on every reminder (#36559). The chat path must keep the prefix stable: leading
|
||||
# system messages still become the ``system`` param, later ones stay in place as
|
||||
# ``role: "system"`` on models flagged ``supports_mid_conversation_system`` and
|
||||
# become a user turn on models that reject the role inside ``messages``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UNFLAGGED_CLAUDE = "claude-opus-4-7"
|
||||
FLAGGED_CLAUDE = "claude-opus-4-8"
|
||||
CONVERTED_SYSTEM_NOTE = (
|
||||
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
|
||||
)
|
||||
REMINDER_TEXT = "<system-reminder>Answer with exactly one word.</system-reminder>"
|
||||
CACHED_SYSTEM_BLOCK = {"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}
|
||||
|
||||
|
||||
def _chat_request(config, model, messages):
|
||||
return config.transform_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def _reminder_conversation():
|
||||
"""The shape Claude Code sends mid-session: cached system prompt, turns, a
|
||||
reminder right after a user turn, an assistant turn, a fresh user turn."""
|
||||
return [
|
||||
{"role": "system", "content": [dict(CACHED_SYSTEM_BLOCK)]},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "user", "content": "Second question"},
|
||||
{"role": "system", "content": REMINDER_TEXT},
|
||||
{"role": "assistant", "content": "Second answer"},
|
||||
{"role": "user", "content": "Third question"},
|
||||
]
|
||||
|
||||
|
||||
def _texts(message):
|
||||
return [block["text"] for block in message["content"] if block.get("type") == "text"]
|
||||
|
||||
|
||||
def test_chat_unflagged_model_converts_mid_conversation_system_to_user_turn(local_model_cost_map):
|
||||
result = _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, _reminder_conversation())
|
||||
|
||||
assert result["system"] == [CACHED_SYSTEM_BLOCK]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
|
||||
assert _texts(result["messages"][2]) == ["Second question", CONVERTED_SYSTEM_NOTE, REMINDER_TEXT]
|
||||
|
||||
|
||||
def test_chat_flagged_model_keeps_mid_conversation_system_in_messages(local_model_cost_map):
|
||||
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, _reminder_conversation())
|
||||
|
||||
assert result["system"] == [CACHED_SYSTEM_BLOCK]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
|
||||
assert result["messages"][3] == {"role": "system", "content": [{"type": "text", "text": REMINDER_TEXT}]}
|
||||
|
||||
|
||||
def test_chat_flagged_model_keeps_cache_control_on_mid_conversation_system(local_model_cost_map):
|
||||
messages = _reminder_conversation()
|
||||
messages[4] = {
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": REMINDER_TEXT, "cache_control": {"type": "ephemeral"}}],
|
||||
}
|
||||
|
||||
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
|
||||
|
||||
assert result["messages"][3]["content"] == [
|
||||
{"type": "text", "text": REMINDER_TEXT, "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_flagged_model_moves_system_after_the_user_turn_it_precedes(local_model_cost_map):
|
||||
"""Anthropic only accepts role=system directly after a user turn; an
|
||||
OpenAI-shaped client that puts the reminder before its next question gets a
|
||||
placement-valid request without the reminder leaving ``messages``."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are terse."},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "system", "content": REMINDER_TEXT},
|
||||
{"role": "user", "content": "Second question"},
|
||||
]
|
||||
|
||||
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
|
||||
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system"]
|
||||
assert _texts(result["messages"][2]) == ["Second question"]
|
||||
assert _texts(result["messages"][3]) == [REMINDER_TEXT]
|
||||
|
||||
|
||||
def test_chat_flagged_model_converts_system_with_no_following_user_turn(local_model_cost_map):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are terse."},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "system", "content": REMINDER_TEXT},
|
||||
]
|
||||
|
||||
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
|
||||
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user"]
|
||||
assert _texts(result["messages"][2]) == [CONVERTED_SYSTEM_NOTE, REMINDER_TEXT]
|
||||
|
||||
|
||||
def test_chat_flagged_model_merges_adjacent_system_messages(local_model_cost_map):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are terse."},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "system", "content": "Reminder one."},
|
||||
{"role": "system", "content": "Reminder two."},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "user", "content": "Second question"},
|
||||
]
|
||||
|
||||
result = _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, messages)
|
||||
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "system", "assistant", "user"]
|
||||
assert _texts(result["messages"][1]) == ["Reminder one.", "Reminder two."]
|
||||
|
||||
|
||||
def test_chat_unflagged_model_keeps_tool_result_first_when_system_precedes_tool_message(local_model_cost_map):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are terse."},
|
||||
{"role": "user", "content": "Weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "system", "content": REMINDER_TEXT},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
|
||||
{"role": "user", "content": "Thanks"},
|
||||
]
|
||||
|
||||
result = _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, messages)
|
||||
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user"]
|
||||
blocks = result["messages"][2]["content"]
|
||||
assert blocks[0]["type"] == "tool_result"
|
||||
assert blocks[0]["tool_use_id"] == "call_1"
|
||||
assert _texts(result["messages"][2]) == [CONVERTED_SYSTEM_NOTE, REMINDER_TEXT, "Thanks"]
|
||||
|
||||
|
||||
def test_chat_transform_request_does_not_mutate_caller_messages(local_model_cost_map):
|
||||
messages = _reminder_conversation()
|
||||
snapshot = copy.deepcopy(messages)
|
||||
|
||||
_chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, messages)
|
||||
|
||||
assert messages == snapshot
|
||||
|
||||
|
||||
_CHAT_CONFIGS = [
|
||||
pytest.param(AnthropicConfig, UNFLAGGED_CLAUDE, id="anthropic-unflagged"),
|
||||
pytest.param(AnthropicConfig, FLAGGED_CLAUDE, id="anthropic-flagged"),
|
||||
pytest.param(VertexAIAnthropicConfig, UNFLAGGED_CLAUDE, id="vertex_ai-unflagged"),
|
||||
pytest.param(VertexAIAnthropicConfig, FLAGGED_CLAUDE, id="vertex_ai-flagged"),
|
||||
pytest.param(AzureAnthropicConfig, UNFLAGGED_CLAUDE, id="azure_ai-unflagged"),
|
||||
pytest.param(AzureAnthropicConfig, FLAGGED_CLAUDE, id="azure_ai-flagged"),
|
||||
pytest.param(AmazonAnthropicClaudeConfig, "invoke/us.anthropic.claude-opus-4-7", id="bedrock_invoke-unflagged"),
|
||||
pytest.param(AmazonAnthropicClaudeConfig, "invoke/us.anthropic.claude-opus-4-8", id="bedrock_invoke-flagged"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_cls, model", _CHAT_CONFIGS)
|
||||
def test_chat_mid_conversation_system_keeps_earlier_turns_a_prefix_of_the_next_request(
|
||||
local_model_cost_map, config_cls, model
|
||||
):
|
||||
"""The provider-side prompt cache is a prefix match over ``system`` +
|
||||
``messages``. Whatever the policy for the reminder, turn N's request must
|
||||
stay a prefix of turn N+1's request or the whole conversation is re-billed.
|
||||
|
||||
Anthropic combines consecutive same-role messages into one turn, so the
|
||||
cache-relevant sequence is ``(role, content block)`` pairs, not the message
|
||||
list: a reminder that joins the preceding user turn still extends the prefix.
|
||||
"""
|
||||
conversation = _reminder_conversation()
|
||||
|
||||
earlier = _chat_request(config_cls(), model, copy.deepcopy(conversation[:4]))
|
||||
later = _chat_request(config_cls(), model, copy.deepcopy(conversation))
|
||||
|
||||
assert later["system"] == earlier["system"]
|
||||
earlier_blocks = _role_block_pairs(earlier["messages"])
|
||||
later_blocks = _role_block_pairs(later["messages"])
|
||||
assert later_blocks[: len(earlier_blocks)] == earlier_blocks
|
||||
assert len(later_blocks) > len(earlier_blocks)
|
||||
|
||||
|
||||
def _role_block_pairs(messages):
|
||||
return [
|
||||
(message["role"], block)
|
||||
for message in messages
|
||||
for block in (message["content"] if isinstance(message["content"], list) else [message["content"]])
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -413,3 +413,51 @@ class TestAzureAnthropicConfig:
|
|||
assert "anthropic-beta" in headers
|
||||
assert "compact-2026-01-12" in headers["anthropic-beta"]
|
||||
assert "context-management-2025-06-27" in headers["anthropic-beta"]
|
||||
|
||||
|
||||
def _mid_conversation_system_conversation():
|
||||
return [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "user", "content": "Second question"},
|
||||
{"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"},
|
||||
{"role": "assistant", "content": "Second answer"},
|
||||
{"role": "user", "content": "Third question"},
|
||||
]
|
||||
|
||||
|
||||
def test_chat_unflagged_model_converts_mid_conversation_system_instead_of_hoisting(local_model_cost_map):
|
||||
"""A hoisted reminder rewrites the top-level system block and invalidates the
|
||||
prompt cache for the whole conversation (#36559)."""
|
||||
result = AzureAnthropicConfig().transform_request(
|
||||
model="claude-opus-4-7",
|
||||
messages=_mid_conversation_system_conversation(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
|
||||
texts = [b["text"] for b in result["messages"][2]["content"] if b.get("type") == "text"]
|
||||
assert texts[0] == "Second question"
|
||||
assert texts[-1] == "<system-reminder>Answer with exactly one word.</system-reminder>"
|
||||
|
||||
|
||||
def test_chat_flagged_model_keeps_mid_conversation_system_role_in_place(local_model_cost_map):
|
||||
result = AzureAnthropicConfig().transform_request(
|
||||
model="claude-opus-4-8",
|
||||
messages=_mid_conversation_system_conversation(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
|
||||
assert result["messages"][3] == {
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "<system-reminder>Answer with exactly one word.</system-reminder>"}],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -542,3 +542,51 @@ def test_output_format_removed_from_bedrock_invoke_request():
|
|||
assert (
|
||||
"output_format" not in result
|
||||
), f"output_format should be removed for Bedrock Invoke, got keys: {result.keys()}"
|
||||
|
||||
|
||||
def _mid_conversation_system_conversation():
|
||||
return [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "user", "content": "Second question"},
|
||||
{"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"},
|
||||
{"role": "assistant", "content": "Second answer"},
|
||||
{"role": "user", "content": "Third question"},
|
||||
]
|
||||
|
||||
|
||||
def test_chat_unflagged_model_converts_mid_conversation_system_instead_of_hoisting(local_model_cost_map):
|
||||
"""A hoisted reminder rewrites the top-level system block and invalidates the
|
||||
prompt cache for the whole conversation (#36559)."""
|
||||
result = AmazonAnthropicClaudeConfig().transform_request(
|
||||
model="invoke/us.anthropic.claude-opus-4-7",
|
||||
messages=_mid_conversation_system_conversation(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
|
||||
texts = [b["text"] for b in result["messages"][2]["content"] if b.get("type") == "text"]
|
||||
assert texts[0] == "Second question"
|
||||
assert texts[-1] == "<system-reminder>Answer with exactly one word.</system-reminder>"
|
||||
|
||||
|
||||
def test_chat_flagged_model_keeps_mid_conversation_system_role_in_place(local_model_cost_map):
|
||||
result = AmazonAnthropicClaudeConfig().transform_request(
|
||||
model="invoke/us.anthropic.claude-opus-4-8",
|
||||
messages=_mid_conversation_system_conversation(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
|
||||
assert result["messages"][3] == {
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "<system-reminder>Answer with exactly one word.</system-reminder>"}],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -727,3 +727,51 @@ def test_sanitize_strips_effort_for_haiku_45():
|
|||
data = {"output_config": {"effort": "high"}}
|
||||
sanitize_vertex_anthropic_output_params(data, "vertex_ai/claude-opus-4-6")
|
||||
assert data["output_config"] == {"effort": "high"}
|
||||
|
||||
|
||||
def _mid_conversation_system_conversation():
|
||||
return [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "user", "content": "Second question"},
|
||||
{"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"},
|
||||
{"role": "assistant", "content": "Second answer"},
|
||||
{"role": "user", "content": "Third question"},
|
||||
]
|
||||
|
||||
|
||||
def test_chat_unflagged_model_converts_mid_conversation_system_instead_of_hoisting(local_model_cost_map):
|
||||
"""A hoisted reminder rewrites the top-level system block and invalidates the
|
||||
prompt cache for the whole conversation (#36559)."""
|
||||
result = VertexAIAnthropicConfig().transform_request(
|
||||
model="claude-opus-4-7",
|
||||
messages=_mid_conversation_system_conversation(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "assistant", "user"]
|
||||
texts = [b["text"] for b in result["messages"][2]["content"] if b.get("type") == "text"]
|
||||
assert texts[0] == "Second question"
|
||||
assert texts[-1] == "<system-reminder>Answer with exactly one word.</system-reminder>"
|
||||
|
||||
|
||||
def test_chat_flagged_model_keeps_mid_conversation_system_role_in_place(local_model_cost_map):
|
||||
result = VertexAIAnthropicConfig().transform_request(
|
||||
model="claude-opus-4-8",
|
||||
messages=_mid_conversation_system_conversation(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["system"] == [{"type": "text", "text": "You are terse.", "cache_control": {"type": "ephemeral"}}]
|
||||
assert [m["role"] for m in result["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"]
|
||||
assert result["messages"][3] == {
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "<system-reminder>Answer with exactly one word.</system-reminder>"}],
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue