This commit is contained in:
Yamac Eren Ay 2026-09-28 11:32:44 +00:00 • committed by GitHub
commit 7647cbc06c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 606 additions and 68 deletions

View file

@ -2,31 +2,44 @@ import warnings
from enum import Enum
from typing import Final, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import (
BaseModel,
Field,
SerializationInfo,
SerializerFunctionWrapHandler,
field_validator,
model_serializer,
model_validator,
)
def validate_different_content(v: str | dict | list) -> str:
if v in ((), {}, []):
return ""
elif isinstance(v, dict) and "text" in v:
return v["text"]
elif isinstance(v, list):
new_v: Final = []
for item in v:
if isinstance(item, dict) and "text" in item:
if item["text"]:
new_v.append(item["text"])
elif isinstance(item, str):
new_v.append(item)
return "\n".join(new_v)
elif isinstance(v, str):
return v
raise ValueError("Content must be a string")
class CacheControl(BaseModel):
type: Literal["ephemeral"]
ttl: Literal["5m", "1h"] | None = None
@model_serializer(mode="wrap")
def _serialize(
self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
) -> dict: # mutable-ok: pydantic serializer contract requires bare dict return
result = handler(self)
if result.get("ttl") is None:
result.pop("ttl", None)
return result
class TextContent(BaseModel):
type_: Literal["text"] = Field(default="text", alias="type")
text: str
cache_control: CacheControl | None = None
@model_serializer(mode="wrap")
def _serialize(
self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
) -> dict: # mutable-ok: pydantic serializer contract requires bare dict return
result = handler(self)
if result.get("cache_control") is None:
result.pop("cache_control", None)
return result
class ImageURLContent(BaseModel):
@ -37,6 +50,16 @@ class ImageURLContent(BaseModel):
class ImageContent(BaseModel):
type_: Literal["image_url"] = Field(default="image_url", alias="type")
image_url: ImageURLContent
cache_control: CacheControl | None = None
@model_serializer(mode="wrap")
def _serialize(
self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
) -> dict: # mutable-ok: pydantic serializer contract requires bare dict return
result = handler(self)
if result.get("cache_control") is None:
result.pop("cache_control", None)
return result
class FunctionObj(BaseModel):
@ -50,9 +73,9 @@ class FunctionTool(BaseModel):
parameters: dict = {"type": "object", "properties": {}}
strict: bool = False
def model_dump(self, **kwargs) -> dict:
def model_dump(self, **kwargs: object) -> dict:
kwargs["exclude_unset"] = False
return super().model_dump(**kwargs)
return super().model_dump(**kwargs) # pyright: ignore[reportArgumentType] # kwargs forwarded verbatim to pydantic model_dump
@field_validator("parameters", mode="before")
@classmethod
@ -70,10 +93,14 @@ class FunctionTool(BaseModel):
class ChatCompletionTool(BaseModel):
type_: Literal["function"] = Field(default="function", alias="type")
function: FunctionTool
cache_control: CacheControl | None = None
def model_dump(self, **kwargs) -> dict:
def model_dump(self, **kwargs: object) -> dict:
kwargs["exclude_unset"] = False
return super().model_dump(**kwargs)
result = super().model_dump(**kwargs) # pyright: ignore[reportArgumentType] # kwargs forwarded verbatim to pydantic model_dump
if result.get("cache_control") is None:
result.pop("cache_control", None)
return result
class MessageToolCall(BaseModel):
@ -88,9 +115,7 @@ class SAPMessage(BaseModel):
"""
role: Literal["system", "developer"] = "system"
content: str
_content_validator = field_validator("content", mode="before")(validate_different_content)
content: str | TextContent | list[TextContent]
class SAPUserMessage(BaseModel):
@ -100,19 +125,15 @@ class SAPUserMessage(BaseModel):
class SAPAssistantMessage(BaseModel):
role: Literal["assistant"] = "assistant"
content: str = ""
content: str | TextContent | list[TextContent] = ""
refusal: str = ""
tool_calls: list[MessageToolCall] = []
_content_validator = field_validator("content", mode="before")(validate_different_content)
class SAPToolChatMessage(BaseModel):
role: Literal["tool"] = "tool"
tool_call_id: str
content: str
_content_validator = field_validator("content", mode="before")(validate_different_content)
content: str | TextContent | list[TextContent]
ChatMessage = SAPMessage | SAPUserMessage | SAPAssistantMessage | SAPToolChatMessage

View file

@ -30,6 +30,7 @@ from .handler import (
)
from .models import (
ChatCompletionTool,
ChatMessage,
OrchestrationRequest,
ResponseFormat,
ResponseFormatJSONSchema,
@ -52,50 +53,61 @@ _SAP_MODEL_PARAMS_EXCLUDED_KEYS: Final[frozenset[str]] = frozenset(
)
def validate_dict(data: dict, model) -> dict:
def validate_dict(
data: dict, model: type
) -> dict: # mutable-ok: pydantic validation boundary; both input and output are untyped wire dicts
return model(**data).model_dump(by_alias=True, exclude_unset=True)
def _fold_message_cache_control(
message: dict[str, object],
) -> dict[str, object]:
"""Move a message-level cache_control marker onto the content block.
litellm's cache_control_injection_points hook attaches cache_control to the
message dict itself when content is a plain string. The SAP message models
have no such top-level field, so it would be silently dropped. When content
is a string, promote it to a one-element list so the marker survives.
When content is already a non-empty list whose last element is a
dict, propagate the marker onto that element — it marks the cache
breakpoint for the whole block. In all other cases (empty list, None,
unrecognized type) the marker is silently dropped.
"""
cache_control = message.get("cache_control")
if cache_control is None:
return message
base = {k: v for k, v in message.items() if k != "cache_control"}
content = message.get("content")
if isinstance(content, str):
base["content"] = [{"type": "text", "text": content, "cache_control": cache_control}]
return base
if isinstance(content, list) and content and isinstance(content[-1], dict):
base["content"] = [
*content[:-1],
{**content[-1], "cache_control": cache_control},
]
return base
return base
def _message_role_mapping(role: str) -> type[ChatMessage]: # returns the SAP message model class, not an instance
return { # mutable-ok: ephemeral dispatch dict; not stored
"user": SAPUserMessage,
"assistant": SAPAssistantMessage,
"tool": SAPToolChatMessage,
}.get(role, SAPMessage)
def _messages_to_sap_template(messages: list[AllMessageValues]) -> list:
template: Final = []
for message in messages:
if message["role"] == "user":
template.append(validate_dict(message, SAPUserMessage))
elif message["role"] == "assistant":
template.append(validate_dict(message, SAPAssistantMessage))
elif message["role"] == "tool":
template.append(validate_dict(message, SAPToolChatMessage))
else:
template.append(validate_dict(message, SAPMessage))
folded_message = _fold_message_cache_control(message)
message_role_class = _message_role_mapping(str(folded_message["role"]))
validated_message = validate_dict(folded_message, message_role_class)
template.append(validated_message)
return template
def _tools_response_format_and_stream(optional_params: dict, model_params: dict) -> tuple[dict, dict, dict]:
tools_ = optional_params.pop("tools", [])
tools_ = [validate_dict(tool, ChatCompletionTool) for tool in tools_]
tools: Final[dict] = {"tools": tools_} if tools_ else {}
response_format = model_params.pop("response_format", {})
resp_type: Final = response_format.get("type", None)
if resp_type:
if resp_type == "json_schema":
response_format = validate_dict(response_format, ResponseFormatJSONSchema)
else:
response_format = validate_dict(response_format, ResponseFormat)
response_format = {"response_format": response_format}
model_params.pop("stream", False)
stream_config: Final[dict] = {}
if "stream_options" in optional_params:
stream_options: Final = optional_params.pop("stream_options", {})
if "chunk_size" in stream_options:
stream_config["chunk_size"] = stream_options.get("chunk_size")
if "delimiters" in stream_options:
stream_config["delimiters"] = stream_options.get("delimiters")
return tools, response_format, stream_config
class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
frequency_penalty: int | None = None
function_call: str | dict | None = None
@ -431,6 +443,6 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
json_mode: bool | None = False,
):
if sync_stream:
return SAPStreamIterator(response=streaming_response)
return SAPStreamIterator(response=streaming_response) # pyright: ignore[reportArgumentType] # streaming_response type matches at runtime
else:
return AsyncSAPStreamIterator(response=streaming_response)
return AsyncSAPStreamIterator(response=streaming_response) # pyright: ignore[reportArgumentType] # streaming_response type matches at runtime

View file

@ -0,0 +1,221 @@
import pytest
from pydantic import ValidationError
from litellm.llms.sap.chat.models import (
SAPAssistantMessage,
SAPMessage,
SAPToolChatMessage,
SAPUserMessage,
TextContent,
)
class TestSAPMessage:
def test_role_system(self):
msg = SAPMessage.model_validate({"role": "system", "content": "Hi"})
assert msg.role == "system"
def test_role_developer(self):
msg = SAPMessage.model_validate({"role": "developer", "content": "Hi"})
assert msg.role == "developer"
def test_role_defaults_to_system(self):
msg = SAPMessage.model_validate({"content": "Hi"})
assert msg.role == "system"
def test_invalid_role_rejected(self):
with pytest.raises(ValidationError):
SAPMessage.model_validate({"role": "user", "content": "Hi"})
def test_missing_content_rejected(self):
with pytest.raises(ValidationError):
SAPMessage.model_validate({"role": "system"})
def test_string_content_accepted(self):
msg = SAPMessage.model_validate({"role": "system", "content": "Hello"})
assert msg.content == "Hello"
def test_text_content_block_accepted(self):
msg = SAPMessage.model_validate({"role": "system", "content": {"type": "text", "text": "Hello"}})
assert isinstance(msg.content, TextContent)
assert msg.content.text == "Hello"
def test_list_of_text_content_blocks_accepted(self):
msg = SAPMessage.model_validate({
"role": "system",
"content": [{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
})
assert isinstance(msg.content, list)
assert len(msg.content) == 2
assert isinstance(msg.content[0], TextContent)
def test_invalid_content_type_rejected(self):
with pytest.raises(ValidationError):
SAPMessage.model_validate({"role": "system", "content": 123})
def test_cache_control_on_content_block(self):
msg = SAPMessage.model_validate({
"role": "system",
"content": {"type": "text", "text": "Hi", "cache_control": {"type": "ephemeral"}},
})
assert isinstance(msg.content, TextContent)
assert msg.content.cache_control is not None
assert msg.content.cache_control.type == "ephemeral"
class TestSAPUserMessage:
def test_role_is_always_user(self):
msg = SAPUserMessage.model_validate({"content": "Hi"})
assert msg.role == "user"
def test_invalid_role_rejected(self):
with pytest.raises(ValidationError):
SAPUserMessage.model_validate({"role": "system", "content": "Hi"})
def test_missing_content_rejected(self):
with pytest.raises(ValidationError):
SAPUserMessage.model_validate({})
def test_string_content_accepted(self):
msg = SAPUserMessage.model_validate({"content": "Hello"})
assert msg.content == "Hello"
def test_text_content_block_accepted(self):
msg = SAPUserMessage.model_validate({"content": {"type": "text", "text": "Hello"}})
assert isinstance(msg.content, TextContent)
def test_image_content_accepted(self):
from litellm.llms.sap.chat.models import ImageContent
msg = SAPUserMessage.model_validate({
"content": {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}
})
assert isinstance(msg.content, ImageContent)
def test_mixed_list_of_text_and_image_accepted(self):
msg = SAPUserMessage.model_validate({
"content": [
{"type": "text", "text": "Look at this:"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
]
})
assert isinstance(msg.content, list)
assert len(msg.content) == 2
def test_invalid_content_type_rejected(self):
with pytest.raises(ValidationError):
SAPUserMessage.model_validate({"content": 123})
def test_cache_control_on_content_block(self):
msg = SAPUserMessage.model_validate({
"content": {"type": "text", "text": "Hi", "cache_control": {"type": "ephemeral"}},
})
assert isinstance(msg.content, TextContent)
assert msg.content.cache_control is not None
assert msg.content.cache_control.type == "ephemeral"
class TestSAPAssistantMessage:
def test_role_is_always_assistant(self):
msg = SAPAssistantMessage.model_validate({"content": "Hi"})
assert msg.role == "assistant"
def test_invalid_role_rejected(self):
with pytest.raises(ValidationError):
SAPAssistantMessage.model_validate({"role": "user", "content": "Hi"})
def test_refusal_defaults_to_empty_string(self):
msg = SAPAssistantMessage.model_validate({})
assert msg.refusal == ""
def test_refusal_accepted(self):
msg = SAPAssistantMessage.model_validate({"refusal": "I cannot help with that."})
assert msg.refusal == "I cannot help with that."
def test_tool_calls_default_to_empty_list(self):
msg = SAPAssistantMessage.model_validate({})
assert msg.tool_calls == []
def test_string_content_accepted(self):
msg = SAPAssistantMessage.model_validate({"content": "Hello"})
assert msg.content == "Hello"
def test_default_empty_string(self):
msg = SAPAssistantMessage.model_validate({})
assert msg.content == ""
def test_text_content_block_accepted(self):
msg = SAPAssistantMessage.model_validate({"content": {"type": "text", "text": "Hello"}})
assert isinstance(msg.content, TextContent)
def test_list_of_text_blocks_accepted(self):
msg = SAPAssistantMessage.model_validate({
"content": [{"type": "text", "text": "A"}, {"type": "text", "text": "B"}]
})
assert isinstance(msg.content, list)
assert len(msg.content) == 2
def test_invalid_content_type_rejected(self):
with pytest.raises(ValidationError):
SAPAssistantMessage.model_validate({"content": {"type": "image_url", "image_url": {"url": "x"}}})
def test_cache_control_on_content_block(self):
msg = SAPAssistantMessage.model_validate({
"content": {"type": "text", "text": "Hi", "cache_control": {"type": "ephemeral"}},
})
assert isinstance(msg.content, TextContent)
assert msg.content.cache_control is not None
assert msg.content.cache_control.type == "ephemeral"
class TestSAPToolChatMessage:
def test_role_is_always_tool(self):
msg = SAPToolChatMessage.model_validate({"tool_call_id": "call_1", "content": "ok"})
assert msg.role == "tool"
def test_invalid_role_rejected(self):
with pytest.raises(ValidationError):
SAPToolChatMessage.model_validate({"role": "user", "tool_call_id": "call_1", "content": "ok"})
def test_tool_call_id_accepted(self):
msg = SAPToolChatMessage.model_validate({"tool_call_id": "call_abc123", "content": "result"})
assert msg.tool_call_id == "call_abc123"
def test_missing_tool_call_id_rejected(self):
with pytest.raises(ValidationError):
SAPToolChatMessage.model_validate({"content": "result"})
def test_string_content_accepted(self):
msg = SAPToolChatMessage.model_validate({"tool_call_id": "call_1", "content": "result"})
assert msg.content == "result"
def test_text_content_block_accepted(self):
msg = SAPToolChatMessage.model_validate({
"tool_call_id": "call_1", "content": {"type": "text", "text": "result"}
})
assert isinstance(msg.content, TextContent)
def test_list_of_text_blocks_accepted(self):
msg = SAPToolChatMessage.model_validate({
"tool_call_id": "call_1",
"content": [{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
})
assert isinstance(msg.content, list)
assert len(msg.content) == 2
def test_missing_content_rejected(self):
with pytest.raises(ValidationError):
SAPToolChatMessage.model_validate({"tool_call_id": "call_1"})
def test_invalid_content_type_rejected(self):
with pytest.raises(ValidationError):
SAPToolChatMessage.model_validate({"tool_call_id": "call_1", "content": 99})
def test_cache_control_on_content_block(self):
msg = SAPToolChatMessage.model_validate({
"tool_call_id": "call_1",
"content": {"type": "text", "text": "result", "cache_control": {"type": "ephemeral"}},
})
assert isinstance(msg.content, TextContent)
assert msg.content.cache_control is not None
assert msg.content.cache_control.type == "ephemeral"

View file

@ -611,6 +611,7 @@ class TestSAPTransformationIntegration:
assert translation["input"]["config"]["source_language"] == "en-US"
assert translation["input"]["config"]["target_language"] == "de-DE"
assert translation["output"]["config"]["target_language"] == "fr-FR"
assert (
config["config"]["modules"][1]["prompt_templating"]["model"]["name"]
== "gpt-5"
@ -639,3 +640,286 @@ class TestSAPTransformationIntegration:
config["config"]["modules"][1]["translation"]["input"]["type"]
== "sap_document_translation"
)
class TestCacheControl:
"""Unit tests for cache_control preservation on message content parts."""
def _transform(self, model: str, messages: list) -> dict:
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
cfg = GenAIHubOrchestrationConfig()
return cfg.transform_request(
model=model,
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
def _template(self, body: dict) -> list:
return body["config"]["modules"]["prompt_templating"]["prompt"]["template"]
def test_cache_control_preserved_on_text_content(self):
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Hello",
"cache_control": {"type": "ephemeral"},
}
],
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
template = self._template(body)
content = template[0]["content"]
assert isinstance(content, list)
assert content[0].get("cache_control") == {"type": "ephemeral"}
def test_plain_text_content_unaffected(self):
messages = [{"role": "user", "content": "Hello"}]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
template = self._template(body)
assert template[0]["content"] == "Hello"
def test_cache_control_preserved_on_multiple_parts(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Part A", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Part B"},
],
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
content = self._template(body)[0]["content"]
assert content[0].get("cache_control") == {"type": "ephemeral"}
assert "cache_control" not in content[1]
def test_cache_control_preserved_on_system_message(self):
messages = [
{
"role": "system",
"content": [
{
"type": "text",
"text": "You are a helpful assistant.",
"cache_control": {"type": "ephemeral"},
}
],
},
{"role": "user", "content": "Hello"},
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
template = self._template(body)
system_content = template[0]["content"]
assert isinstance(system_content, list)
assert system_content[0].get("cache_control") == {"type": "ephemeral"}
def test_system_message_without_cache_control_stays_string(self):
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
template = self._template(body)
assert isinstance(template[0]["content"], str)
def test_cache_control_preserved_on_tool_definition(self):
tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
"cache_control": {"type": "ephemeral"},
}
messages = [{"role": "user", "content": "Hi"}]
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
cfg = GenAIHubOrchestrationConfig()
body = cfg.transform_request(
model="anthropic--claude-3-5-sonnet",
messages=messages,
optional_params={"tools": [tool]},
litellm_params={},
headers={},
)
tools = body["config"]["modules"]["prompt_templating"]["prompt"]["tools"]
assert tools[0].get("cache_control") == {"type": "ephemeral"}
def test_tool_without_cache_control_omits_field(self):
tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
}
messages = [{"role": "user", "content": "Hi"}]
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
cfg = GenAIHubOrchestrationConfig()
body = cfg.transform_request(
model="anthropic--claude-3-5-sonnet",
messages=messages,
optional_params={"tools": [tool]},
litellm_params={},
headers={},
)
tools = body["config"]["modules"]["prompt_templating"]["prompt"]["tools"]
assert "cache_control" not in tools[0]
def test_message_level_cache_control_folded_onto_string_content(self):
messages = [
{
"role": "system",
"content": "You are a helpful assistant.",
"cache_control": {"type": "ephemeral"},
},
{"role": "user", "content": "Hello"},
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
template = self._template(body)
system_content = template[0]["content"]
assert isinstance(system_content, list), "string content should have been promoted to a list"
assert system_content[0]["text"] == "You are a helpful assistant."
assert system_content[0].get("cache_control") == {"type": "ephemeral"}
def test_null_cache_control_on_content_block_is_omitted(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello", "cache_control": None},
],
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
content = self._template(body)[0]["content"]
assert isinstance(content, list)
assert "cache_control" not in content[0]
def test_message_level_cache_control_folded_on_user_message(self):
messages = [
{
"role": "user",
"content": "What is the weather today?",
"cache_control": {"type": "ephemeral"},
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
content = self._template(body)[0]["content"]
assert isinstance(content, list), "string content should have been promoted to a list"
assert content[0]["text"] == "What is the weather today?"
assert content[0].get("cache_control") == {"type": "ephemeral"}
def test_cache_control_ttl_omitted_when_absent(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}},
],
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
cc = self._template(body)[0]["content"][0]["cache_control"]
assert cc == {"type": "ephemeral"}
assert "ttl" not in cc
def test_cache_control_ttl_preserved_when_set(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral", "ttl": "5m"}},
],
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
cc = self._template(body)[0]["content"][0]["cache_control"]
assert cc == {"type": "ephemeral", "ttl": "5m"}
def test_message_level_cache_control_folded_on_assistant_message(self):
messages = [
{"role": "user", "content": "Hi"},
{
"role": "assistant",
"content": "Hello! How can I help?",
"cache_control": {"type": "ephemeral"},
},
{"role": "user", "content": "Continue"},
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
template = self._template(body)
assistant_content = template[1]["content"]
assert isinstance(assistant_content, list), "string content should have been promoted to a list"
assert assistant_content[0]["text"] == "Hello! How can I help?"
assert assistant_content[0].get("cache_control") == {"type": "ephemeral"}
def test_message_level_cache_control_folded_on_tool_message(self):
messages = [
{"role": "user", "content": "What is the weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "Sunny, 22C",
"cache_control": {"type": "ephemeral"},
},
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
template = self._template(body)
tool_content = template[2]["content"]
assert isinstance(tool_content, list), "string content should have been promoted to a list"
assert tool_content[0]["text"] == "Sunny, 22C"
assert tool_content[0].get("cache_control") == {"type": "ephemeral"}
def test_message_level_cache_control_folded_onto_last_list_element(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Part A"},
{"type": "text", "text": "Part B"},
],
"cache_control": {"type": "ephemeral"},
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
content = self._template(body)[0]["content"]
assert "cache_control" not in content[0]
assert content[1].get("cache_control") == {"type": "ephemeral"}
def test_message_level_cache_control_folded_onto_trailing_image_block(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image."},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc="}},
],
"cache_control": {"type": "ephemeral"},
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
content = self._template(body)[0]["content"]
assert "cache_control" not in content[0]
assert content[1].get("cache_control") == {"type": "ephemeral"}