This commit is contained in:
Yamac Eren Ay 2026-09-12 11:47:11 +00:00 committed by GitHub
commit a34a4d98c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 654 additions and 94 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: str | 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

@ -31,6 +31,7 @@ from .handler import (
)
from .models import (
ChatCompletionTool,
ChatMessage,
OrchestrationRequest,
ResponseFormat,
ResponseFormatJSONSchema,
@ -53,50 +54,65 @@ _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 _messages_to_sap_template(messages: list[dict[str, str]]) -> list:
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.
"""
cc = message.get("cache_control")
if cc is None:
return message
base = {
k: v for k, v in message.items() if k != "cache_control"
} # mutable-ok: new dict built from message; returned immediately
content = message.get("content")
if isinstance(content, str):
base["content"] = [
{"type": "text", "text": content, "cache_control": cc}
] # mutable-ok: new list+dict built and assigned to output dict
return base
if isinstance(content, list) and content and isinstance(content[-1], dict):
base["content"] = [
*content[:-1],
{**content[-1], "cache_control": cc},
] # mutable-ok: new list+dict built and assigned to output dict
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[dict[str, object]]) -> 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
@ -432,6 +448,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

@ -1,4 +1,5 @@
import warnings
import pytest
from pydantic import ValidationError
@ -611,31 +612,332 @@ 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"
assert config["config"]["modules"][0]["prompt_templating"]["model"]["name"] == "gpt-4o"
assert config["config"]["modules"][0]["prompt_templating"]["model"]["params"] == {}
assert (
config["config"]["modules"][1]["prompt_templating"]["model"]["name"]
== "gpt-5"
)
assert (
config["config"]["modules"][0]["prompt_templating"]["model"]["name"]
== "gpt-4o"
)
assert (
config["config"]["modules"][0]["prompt_templating"]["model"]["params"]
== {}
)
assert (
config["config"]["modules"][1]["prompt_templating"]["prompt"][
"template"
][0]["content"]
config["config"]["modules"][1]["prompt_templating"]["prompt"]["template"][0]["content"]
== "Hello world!"
)
assert (
config["config"]["modules"][0]["prompt_templating"]["prompt"][
"template"
][0]["content"]
== "Hello."
)
assert (
config["config"]["modules"][1]["translation"]["input"]["type"]
== "sap_document_translation"
)
assert config["config"]["modules"][0]["prompt_templating"]["prompt"]["template"][0]["content"] == "Hello."
assert 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):
"""cache_control on a TextContent part survives validation and appears in payload."""
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):
"""Text content without cache_control still serialises cleanly."""
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):
"""cache_control is preserved on each part independently."""
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):
"""cache_control on a system message content part reaches the payload."""
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):
"""A plain system message is still serialised as a string, not a list."""
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):
"""cache_control on a tool definition reaches the payload."""
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):
"""A tool without cache_control does not emit cache_control: null."""
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):
"""cache_control on the message dict (string content) is folded into a content block.
litellm's cache_control_injection_points hook produces this shape for
plain-string content. The marker must not be silently dropped.
"""
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):
"""cache_control: null on a content block must not appear in the payload.
Sending null reaches AI Core and causes a 400 ('None is not of type object').
"""
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):
"""cache_control on the message dict (string user content) is folded into a content block.
The injection hook can attach cache_control at the message level for any role.
User messages must receive the same folding treatment as system messages.
"""
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):
"""cache_control without ttl must not emit ttl: null in the payload."""
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):
"""cache_control with ttl must include ttl in the payload."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral", "ttl": "300"}},
],
}
]
body = self._transform("anthropic--claude-3-5-sonnet", messages)
cc = self._template(body)[0]["content"][0]["cache_control"]
assert cc == {"type": "ephemeral", "ttl": "300"}
def test_message_level_cache_control_folded_on_assistant_message(self):
"""cache_control on an assistant turn is folded into a content block."""
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):
"""cache_control on a tool result is folded into a content block."""
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):
"""A top-level cache_control with list content marks the last element.
Per the Anthropic spec, a message-level marker indicates the cache
breakpoint for the whole block. It must land on the final content
part, not be silently dropped.
"""
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):
"""A top-level cache_control with a trailing image part lands on that image.
The Anthropic spec allows cache_control on any content block including
image_url. Previously ImageContent had no cache_control field, so the
marker was silently dropped by Pydantic validation.
"""
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"}