test(sap): fold cache_control coverage into the mapped transformation file

tests/test_litellm/ mirrors litellm/ one to one, and the convention for a bug fix
is to extend the existing mapped test file rather than add a sibling next to it,
so the cache_control regressions move into test_sap_transformation.py and
test_sap_cache_control.py goes away

The helpers also drop the docstrings they were carrying, since comments are
reserved here for genuinely complex business logic, and validate_cacheable_content
now returns a tuple instead of a list to stay on the immutable side of LIT001
This commit is contained in:
Joseph Bergin 2026-08-26 15:45:17 -05:00
parent 9f9fbe4991
commit 10752af822
3 changed files with 127 additions and 306 deletions

View file

@ -6,7 +6,7 @@ from typing import Final, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
def validate_different_content(v: str | dict | list) -> str:
def validate_different_content(v: str | Mapping[str, object] | Sequence[object]) -> str:
if v in ((), {}, []):
return ""
elif isinstance(v, dict) and "text" in v:
@ -26,7 +26,6 @@ def validate_different_content(v: str | dict | list) -> str:
def _has_cache_control(v: str | Mapping[str, object] | Sequence[object]) -> bool:
"""True if any content block carries a ``cache_control`` breakpoint."""
if isinstance(v, Mapping):
return v.get("cache_control") is not None
if isinstance(v, str):
@ -36,30 +35,16 @@ def _has_cache_control(v: str | Mapping[str, object] | Sequence[object]) -> bool
def validate_cacheable_content(
v: str | Mapping[str, object] | Sequence[object],
) -> str | Sequence[Mapping[str, object]]:
"""Flatten content to a string, keeping the block form when it is cached.
SAP Orchestration accepts either a plain string or a list of ``text`` blocks for
system/developer messages. Flattening unconditionally drops any ``cache_control``
breakpoint set on a block, so prompt caching never activates on the ``sap/`` route.
Keep the block form only when a breakpoint is present, leaving every other request
byte-for-byte unchanged.
"""
) -> str | tuple[Mapping[str, object], ...]:
if not _has_cache_control(v):
return validate_different_content(v)
blocks: Final = (v,) if isinstance(v, Mapping) else v
normalized: Final = ({"type": "text", "text": item} if isinstance(item, str) else item for item in blocks)
return [block for block in normalized if isinstance(block, Mapping) and block.get("text")]
return tuple(block for block in normalized if isinstance(block, Mapping) and block.get("text"))
class CacheControl(BaseModel):
"""Prompt-cache breakpoint forwarded to the model provider.
SAP Orchestration passes this through to Bedrock-hosted Anthropic Claude and
Amazon Nova models, which use it to mark where the cached prefix ends.
"""
type_: Literal["ephemeral"] = Field(default="ephemeral", alias="type")
ttl: str | None = None

View file

@ -1,191 +0,0 @@
"""Regression tests for cache_control passthrough on the SAP Orchestration route.
SAP Orchestration forwards a ``cache_control`` breakpoint to Bedrock-hosted
Anthropic Claude and Amazon Nova models. The transformation used to drop those
breakpoints, so prompt caching silently never activated (BerriAI/litellm#37866).
"""
import pytest
from litellm.llms.sap.chat.transformation import (
GenAIHubOrchestrationConfig,
_messages_to_sap_template,
)
EPHEMERAL = {"type": "ephemeral"}
def _template(result: dict) -> list:
return result["config"]["modules"]["prompt_templating"]["prompt"]["template"]
@pytest.fixture
def mock_config():
config = GenAIHubOrchestrationConfig()
config.token_creator = lambda: "Bearer TEST_TOKEN"
config._base_url = "https://api.test-sap.com"
config._resource_group = "test-group"
return config
class TestSAPCacheControl:
"""cache_control breakpoints must survive the SAP transformation."""
def test_system_message_keeps_breakpoint(self):
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "Long prefix", "cache_control": EPHEMERAL}],
}
]
template = _messages_to_sap_template(messages)
assert template == [
{
"role": "system",
"content": [{"type": "text", "text": "Long prefix", "cache_control": EPHEMERAL}],
}
]
def test_user_message_keeps_breakpoint(self):
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "Summarize.", "cache_control": EPHEMERAL}],
}
]
template = _messages_to_sap_template(messages)
assert template[0]["content"][0]["cache_control"] == EPHEMERAL
def test_breakpoint_survives_full_transform_request(self, mock_config):
"""The breakpoint must still be there in the body actually sent to SAP."""
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "Cached prefix", "cache_control": EPHEMERAL}],
},
{
"role": "user",
"content": [{"type": "text", "text": "Question?", "cache_control": EPHEMERAL}],
},
]
result = mock_config.transform_request("anthropic--claude-4.5-haiku", messages, {}, {}, {})
template = _template(result)
assert template[0]["content"][0]["cache_control"] == EPHEMERAL
assert template[1]["content"][0]["cache_control"] == EPHEMERAL
def test_ttl_is_preserved(self):
"""Anthropic's extended cache TTL must not be dropped either."""
cache_control = {"type": "ephemeral", "ttl": "1h"}
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "Hi", "cache_control": cache_control}],
}
]
template = _messages_to_sap_template(messages)
assert template[0]["content"][0]["cache_control"] == cache_control
def test_multiple_breakpoints_are_all_kept(self):
messages = [
{
"role": "system",
"content": [
{"type": "text", "text": "A", "cache_control": EPHEMERAL},
{"type": "text", "text": "B"},
{"type": "text", "text": "C", "cache_control": EPHEMERAL},
],
}
]
content = _messages_to_sap_template(messages)[0]["content"]
assert [block.get("cache_control") for block in content] == [
EPHEMERAL,
None,
EPHEMERAL,
]
def test_plain_string_alongside_cached_block_becomes_a_text_block(self):
messages = [
{
"role": "system",
"content": ["raw string", {"type": "text", "text": "B", "cache_control": EPHEMERAL}],
}
]
content = _messages_to_sap_template(messages)[0]["content"]
assert content == [
{"type": "text", "text": "raw string"},
{"type": "text", "text": "B", "cache_control": EPHEMERAL},
]
class TestSAPCacheControlNoRegression:
"""Requests without a breakpoint must be byte-for-byte unchanged."""
@pytest.mark.parametrize(
"message, expected",
[
(
{"role": "system", "content": "You are helpful."},
{"role": "system", "content": "You are helpful."},
),
(
{
"role": "system",
"content": [{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
},
{"role": "system", "content": "A\nB"},
),
(
{"role": "system", "content": {"type": "text", "text": "solo"}},
{"role": "system", "content": "solo"},
),
(
{"role": "system", "content": []},
{"role": "system", "content": ""},
),
(
{"role": "developer", "content": [{"type": "text", "text": "D"}]},
{"role": "developer", "content": "D"},
),
(
{"role": "user", "content": "hi"},
{"role": "user", "content": "hi"},
),
(
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
),
],
)
def test_uncached_messages_are_unchanged(self, message, expected):
assert _messages_to_sap_template([message]) == [expected]
def test_assistant_and_tool_messages_still_flatten(self):
"""SAP's own SDK skips assistant/tool messages when applying cache_control."""
messages = [
{
"role": "assistant",
"content": [{"type": "text", "text": "ans", "cache_control": EPHEMERAL}],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": [{"type": "text", "text": "res", "cache_control": EPHEMERAL}],
},
]
template = _messages_to_sap_template(messages)
assert template[0]["content"] == "ans"
assert template[1]["content"] == "res"

View file

@ -34,9 +34,7 @@ class TestSAPTransformationIntegration:
result = mock_config.transform_request(model, messages, optional_params, {}, {})
model_params = result["config"]["modules"]["prompt_templating"]["model"][
"params"
]
model_params = result["config"]["modules"]["prompt_templating"]["model"]["params"]
assert "temperature" in model_params
assert "frequency_penalty" in model_params
@ -44,18 +42,16 @@ class TestSAPTransformationIntegration:
assert "model_version" not in model_params
assert "tools" not in model_params
model_version = result["config"]["modules"]["prompt_templating"]["model"][
"version"
]
model_version = result["config"]["modules"]["prompt_templating"]["model"]["version"]
assert model_version == "v1.5"
prompt = result["config"]["modules"]["prompt_templating"]["prompt"]
if "tools" in prompt:
assert isinstance(prompt["tools"], list)
for tool in prompt["tools"]:
assert (
tool["function"]["parameters"]["type"] == "object"
), "SAP API requires parameters.type == 'object'"
assert tool["function"]["parameters"]["type"] == "object", (
"SAP API requires parameters.type == 'object'"
)
assert "properties" in tool["function"]["parameters"]
def test_transform_request_parameter_handling_robustness(self, mock_config):
@ -96,33 +92,25 @@ class TestSAPTransformationIntegration:
for i, test_case in enumerate(test_cases):
filtered_params = {
k: v
for k, v in test_case["params"].items()
if k not in {"tools", "model_version", "deployment_url"}
k: v for k, v in test_case["params"].items() if k not in {"tools", "model_version", "deployment_url"}
}
for expected_param in test_case["expected_in_model"]:
assert (
expected_param in filtered_params
), f"Case {i + 1}: {expected_param} should be in model params"
assert expected_param in filtered_params, f"Case {i + 1}: {expected_param} should be in model params"
for excluded_param in test_case["expected_excluded"]:
assert (
excluded_param not in filtered_params
), f"Case {i + 1}: {excluded_param} should be excluded from model params"
assert excluded_param not in filtered_params, (
f"Case {i + 1}: {excluded_param} should be excluded from model params"
)
result = mock_config.transform_request(
model, messages, test_case["params"], {}, {}
)
result = mock_config.transform_request(model, messages, test_case["params"], {}, {})
if result and "config" in result:
model_params = result["config"]["modules"]["prompt_templating"][
"model"
]["params"]
model_params = result["config"]["modules"]["prompt_templating"]["model"]["params"]
for excluded_param in test_case["expected_excluded"]:
assert (
excluded_param not in model_params
), f"Case {i + 1}: {excluded_param} should not be in actual model params"
assert excluded_param not in model_params, (
f"Case {i + 1}: {excluded_param} should not be in actual model params"
)
def test_config_transform_with_response_format_json_object(self, mock_config):
expected_dict = {
@ -145,9 +133,7 @@ class TestSAPTransformationIntegration:
}
config = mock_config.transform_request(
model="gpt-4o",
messages=[
{"role": "user", "content": "First man on the moon, answer in json"}
],
messages=[{"role": "user", "content": "First man on the moon, answer in json"}],
optional_params={
"response_format": {"type": "json_object"},
"deployment_url": "shouldn't be in results",
@ -189,9 +175,7 @@ class TestSAPTransformationIntegration:
config = mock_config.transform_request(
model="gpt-4o",
messages=[
{"role": "user", "content": "First man on the moon, answer in json"}
],
messages=[{"role": "user", "content": "First man on the moon, answer in json"}],
optional_params={
"response_format": expected_response_format,
"deployment_url": "shouldn't be in results",
@ -199,27 +183,15 @@ class TestSAPTransformationIntegration:
litellm_params={},
headers={},
)
assert (
config["config"]["modules"]["prompt_templating"]["prompt"][
"response_format"
]
== expected_response_format
)
assert (
len(config["config"]["modules"]["prompt_templating"]["model"]["params"])
== 0
)
assert config["config"]["modules"]["prompt_templating"]["prompt"]["response_format"] == expected_response_format
assert len(config["config"]["modules"]["prompt_templating"]["model"]["params"]) == 0
def test_config_transform_with_stream(self, mock_config):
expected_dict = {
"config": {
"modules": {
"prompt_templating": {
"prompt": {
"template": [
{"role": "user", "content": "Hello, how are you?"}
]
},
"prompt": {"template": [{"role": "user", "content": "Hello, how are you?"}]},
"model": {
"name": "anthropic--claude-4-sonnet",
"params": {},
@ -257,9 +229,7 @@ class TestSAPTransformationIntegration:
headers={},
)
assert config["config"]["modules"]["prompt_templating"]["prompt"][
"defaults"
] == {"user_query": "default value"}
assert config["config"]["modules"]["prompt_templating"]["prompt"]["defaults"] == {"user_query": "default value"}
assert config["config"]["modules"]["prompt_templating"]["model"]["params"] == {}
def test_sap_placeholder_values(self, mock_config):
@ -323,14 +293,8 @@ class TestSAPTransformationIntegration:
assert config["placeholder_values"] == placeholder_values
modules = config["config"]["modules"]
assert modules["grounding"]["type"] == "document_grounding_service"
assert (
modules["grounding"]["config"]["placeholders"]["output"]
== "grounding_response"
)
assert (
modules["grounding"]["config"]["filters"][0]["data_repository_type"]
== "vector"
)
assert modules["grounding"]["config"]["placeholders"]["output"] == "grounding_response"
assert modules["grounding"]["config"]["filters"][0]["data_repository_type"] == "vector"
assert modules["prompt_templating"]["model"]["params"] == {}
def test_grounding_search_config_rejects_both_count_fields(self, mock_config):
@ -442,10 +406,7 @@ class TestSAPTransformationIntegration:
headers={},
)
assert (
"For using SAP Filtering Module you must provide at least one property"
in str(exc_info.value)
)
assert "For using SAP Filtering Module you must provide at least one property" in str(exc_info.value)
def test_sap_masking(self, mock_config):
masking_config = {
@ -509,9 +470,7 @@ class TestSAPTransformationIntegration:
headers={},
)
assert "must set exactly one of: 'providers' or 'masking_providers'" in str(
exc_info.value
)
assert "must set exactly one of: 'providers' or 'masking_providers'" in str(exc_info.value)
def test_masking_providers_deprecated_emits_warning(self, mock_config):
masking_config = {
@ -533,8 +492,7 @@ class TestSAPTransformationIntegration:
headers={},
)
assert any(
issubclass(warning.category, DeprecationWarning)
and "masking_providers" in str(warning.message)
issubclass(warning.category, DeprecationWarning) and "masking_providers" in str(warning.message)
for warning in w
), "Expected DeprecationWarning for 'masking_providers'"
@ -573,10 +531,7 @@ class TestSAPTransformationIntegration:
headers={},
)
assert (
"TranslationModuleConfig requires at least one of 'input' or 'output'"
in str(exc_info.value)
)
assert "TranslationModuleConfig requires at least one of 'input' or 'output'" in str(exc_info.value)
def test_sap_multiple_modules(self, mock_config):
translation_config = {
@ -611,31 +566,103 @@ 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"
@pytest.mark.parametrize(
"message, expected",
[
({"role": "system", "content": "You are helpful."}, {"role": "system", "content": "You are helpful."}),
(
{"role": "system", "content": [{"type": "text", "text": "A"}, {"type": "text", "text": "B"}]},
{"role": "system", "content": "A\nB"},
),
({"role": "system", "content": {"type": "text", "text": "solo"}}, {"role": "system", "content": "solo"}),
({"role": "system", "content": []}, {"role": "system", "content": ""}),
({"role": "developer", "content": [{"type": "text", "text": "D"}]}, {"role": "developer", "content": "D"}),
({"role": "user", "content": "hi"}, {"role": "user", "content": "hi"}),
(
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
),
],
)
def test_messages_without_cache_control_are_unchanged(self, message, expected):
"""Messages with no breakpoint keep the exact shape they had before cache_control support."""
from litellm.llms.sap.chat.transformation import _messages_to_sap_template
assert _messages_to_sap_template([message]) == [expected]
def test_cache_control_survives_transform_request(self, mock_config):
"""Breakpoints on system and user blocks reach the body sent to SAP."""
ephemeral = {"type": "ephemeral"}
messages = [
{"role": "system", "content": [{"type": "text", "text": "Cached prefix", "cache_control": ephemeral}]},
{"role": "user", "content": [{"type": "text", "text": "Question?", "cache_control": ephemeral}]},
]
result = mock_config.transform_request("anthropic--claude-4.5-haiku", messages, {}, {}, {})
template = result["config"]["modules"]["prompt_templating"]["prompt"]["template"]
assert template[0]["content"][0]["cache_control"] == ephemeral
assert template[1]["content"][0]["cache_control"] == ephemeral
def test_cache_control_ttl_is_preserved(self):
"""Anthropic's extended cache TTL rides along with the breakpoint."""
from litellm.llms.sap.chat.transformation import _messages_to_sap_template
cache_control = {"type": "ephemeral", "ttl": "1h"}
messages = [{"role": "user", "content": [{"type": "text", "text": "Hi", "cache_control": cache_control}]}]
template = _messages_to_sap_template(messages)
assert template[0]["content"][0]["cache_control"] == cache_control
def test_cached_system_content_normalizes_mixed_blocks(self):
"""A cached block keeps its marker, plain strings beside it become text blocks, empties drop."""
from litellm.llms.sap.chat.transformation import _messages_to_sap_template
ephemeral = {"type": "ephemeral"}
messages = [
{
"role": "system",
"content": [
"raw string",
{"type": "text", "text": ""},
{"type": "text", "text": "B", "cache_control": ephemeral},
],
}
]
content = _messages_to_sap_template(messages)[0]["content"]
assert list(content) == [
{"type": "text", "text": "raw string"},
{"type": "text", "text": "B", "cache_control": ephemeral},
]
def test_assistant_and_tool_messages_still_flatten(self):
"""SAP's own SDK skips assistant and tool roles when applying cache_control, so they stay strings."""
from litellm.llms.sap.chat.transformation import _messages_to_sap_template
ephemeral = {"type": "ephemeral"}
messages = [
{"role": "assistant", "content": [{"type": "text", "text": "ans", "cache_control": ephemeral}]},
{
"role": "tool",
"tool_call_id": "call_1",
"content": [{"type": "text", "text": "res", "cache_control": ephemeral}],
},
]
template = _messages_to_sap_template(messages)
assert template[0]["content"] == "ans"
assert template[1]["content"] == "res"