mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge 6ef0eed7c7 into 9071ca503e
This commit is contained in:
commit
a34a4d98c0
4 changed files with 654 additions and 94 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
tests/test_litellm/llms/sap/chat/test_sap_models.py
Normal file
221
tests/test_litellm/llms/sap/chat/test_sap_models.py
Normal 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"
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue