From 7d93ab5db1b99036f255b4968b1284c8af773770 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Thu, 10 Sep 2026 15:51:14 +0200 Subject: [PATCH 1/6] fix(sap): introduce cache_control redone --- .../gen_ai_hub/orchestration_v2/__init__.py | 5 +- .../orchestration_v2/models/__init__.py | 8 ++- .../orchestration_v2/models/cache_control.py | 52 +++++++++++++++++++ .../models/multimodal_items.py | 10 +++- .../orchestration_v2/models/response.py | 24 ++++++++- .../orchestration_v2/models/tools.py | 5 ++ .../orchestration_v2/test_flat_import.py | 9 ++-- 7 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py diff --git a/packages/gen/gen_ai_hub/orchestration_v2/__init__.py b/packages/gen/gen_ai_hub/orchestration_v2/__init__.py index 260f0c7e..d378b8f7 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/__init__.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/__init__.py @@ -3,6 +3,9 @@ from .exceptions import OrchestrationError, OrchestrationErrorList __all__ = [ + # cache_control + "CacheControl", + # azure_content_filter "AzureContentFilter", "AzureContentSafetyInput", "AzureContentSafetyOutput", "AzureThreshold", @@ -46,7 +49,7 @@ "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", # response - "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", + "CacheCreationTokenDetails", "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", "ChatCompletionTokenLogprob", "ChoiceLogprobs", "LLMChoice", "StreamFunctionObject", "StreamToolCall", "StreamDelta", "StreamLLMChoice", "Citation", "LLMModuleResult", "StreamLLMModuleResult", "ModuleResults", "StreamModuleResults", "SAPAPIError", "SAPAPIErrorStreaming", "CompletionPostResponse", diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py b/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py index 790b0b75..44e231e7 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py @@ -1,4 +1,5 @@ from .azure_content_filter import AzureContentSafetyInput, AzureContentSafetyOutput, AzureContentFilter, AzureThreshold +from .cache_control import CacheControl from .config import (ModuleConfig, OrchestrationConfig, OrchestrationConfigReference, CompletionRequestConfigurationReferenceByIdConfigRef, CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef) @@ -20,7 +21,7 @@ from .message import (SystemMessage, UserMessage, AssistantMessage, ToolChatMessage, DeveloperChatMessage, ChatMessage, ResponseChatMessage, FunctionCall, MessageToolCall) from .multimodal_items import ImageDetailLevel, TextPart, ImageUrl, ImagePart, ContentPart, ImageItem -from .response import (PromptTokensDetails, CompletionTokensDetails, TokenUsage, GenericModuleResult, TopLogprob, +from .response import (CacheCreationTokenDetails, PromptTokensDetails, CompletionTokensDetails, TokenUsage, GenericModuleResult, TopLogprob, ChatCompletionTokenLogprob, ChoiceLogprobs, LLMChoice, StreamFunctionObject, StreamToolCall, StreamDelta, StreamLLMChoice, Citation, LLMModuleResult, StreamLLMModuleResult, ModuleResults, StreamModuleResults, SAPAPIError, SAPAPIErrorStreaming, CompletionPostResponse, @@ -36,6 +37,9 @@ __all__ = [ + # cache_control + "CacheControl", + # azure_content_filter "AzureContentFilter", "AzureContentSafetyInput", "AzureContentSafetyOutput", "AzureThreshold", @@ -79,7 +83,7 @@ "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", # response - "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", + "CacheCreationTokenDetails", "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", "ChatCompletionTokenLogprob", "ChoiceLogprobs", "LLMChoice", "StreamFunctionObject", "StreamToolCall", "StreamDelta", "StreamLLMChoice", "Citation", "LLMModuleResult", "StreamLLMModuleResult", "ModuleResults", "StreamModuleResults", "SAPAPIError", "SAPAPIErrorStreaming", "CompletionPostResponse", diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py b/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py new file mode 100644 index 00000000..348d7d95 --- /dev/null +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py @@ -0,0 +1,52 @@ +"""Cache control for prompt caching on supported Anthropic and Amazon Nova models.""" +from typing import Any, Dict, Literal, Optional + +from pydantic import model_serializer + +from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel + + +class CacheControl(BaseModel): + """Marks a message content block or tool definition for prompt caching. + + When attached to a content block, the model stores intermediate computation + results for that content and reuses them on subsequent requests within the + TTL window, reducing both latency and token costs. + + Supported models: + - Anthropic Claude: system and user content blocks; tools. + - Amazon Nova: system and user content blocks only (no tools, no TTL). + + Attach ``CacheControl`` directly to a content block (``TextPart``, ``ImagePart``) or + to a ``ChatCompletionTool``. For the "last-block shorthand" pass ``cache_control`` to + ``OrchestrationService.run()``; it calls ``apply_cache_control_to_last_message()`` + automatically. + + Args: + type: Always ``"ephemeral"``. Only value supported by the API. + ttl: Cache duration. ``"5m"`` (default) or ``"1h"`` (select Anthropic + models only). Omit for Amazon Nova or when the default is sufficient. + + Example:: + + from gen_ai_hub.orchestration_v2.models.cache_control import CacheControl + from gen_ai_hub.orchestration_v2.models.multimodal_items import TextPart + + block = TextPart(text="Long context...", cache_control=CacheControl()) + block_1h = TextPart(text="Long context...", cache_control=CacheControl(ttl="1h")) + """ + + type: Literal["ephemeral"] = "ephemeral" + ttl: Optional[Literal["5m", "1h"]] = None + + @model_serializer(mode="wrap") + def serialize_wire_format(self, handler: Any) -> Dict[str, Any]: + """Serialize to the wire format, omitting ``ttl`` when not set. + + :return: ``{"type": "ephemeral"}`` or ``{"type": "ephemeral", "ttl": ""}`` + :rtype: dict + """ + data: Dict[str, Any] = handler(self) + if data.get("ttl") is None: + data.pop("ttl", None) + return data diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py b/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py index 87855b24..9f17c893 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py @@ -12,6 +12,7 @@ from pydantic.main import IncEx from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel +from gen_ai_hub.orchestration_v2.models.cache_control import CacheControl class ImageDetailLevel(Enum): @@ -38,9 +39,13 @@ class TextPart(BaseModel): text: The string content of the text part. type: The type identifier, defaulting to "text". + + cache_control: Optional cache control settings for prompt caching. + Only supported for Anthropic Claude and Amazon Nova models. """ text: str type_: Literal["text"] = Field(default="text", alias="type") + cache_control: Optional[CacheControl] = Field(default=None, exclude=False) class ImageUrl(BaseModel): @@ -56,7 +61,6 @@ class ImageUrl(BaseModel): detail: Optional[ImageDetailLevel] = None -# @dataclass class ImagePart(BaseModel): """ Represents an image segment within a multimodal content block. @@ -65,9 +69,13 @@ class ImagePart(BaseModel): image_url: An `ImageUrl` object containing the image's location and detail level. type: The type identifier, defaulting to "image_url". + + cache_control: Optional cache control settings for prompt caching. + Only supported for Anthropic Claude models. """ image_url: ImageUrl type_: Literal["image_url"] = Field(default="image_url", alias="type") + cache_control: Optional[CacheControl] = Field(default=None, exclude=False) ContentPart = Union[TextPart, ImagePart] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/response.py b/packages/gen/gen_ai_hub/orchestration_v2/models/response.py index ca398edf..cc6c6449 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/response.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/response.py @@ -27,16 +27,35 @@ class ResponseBaseModel(BaseModel): ) +class CacheCreationTokenDetails(ResponseBaseModel): + """ + Per-TTL breakdown of tokens written to the prompt cache. + + Present only when cache_control includes an explicit ttl value. + + Attributes: + ephemeral_5m_input_tokens: Tokens cached with a 5-minute TTL. + ephemeral_1h_input_tokens: Tokens cached with a 1-hour TTL. + """ + ephemeral_5m_input_tokens: Optional[int] = None + ephemeral_1h_input_tokens: Optional[int] = None + + class PromptTokensDetails(ResponseBaseModel): """ Represents the details of prompt tokens used in a specific operation. Attributes: audio_tokens (Optional[int]): Audio input tokens present in the prompt. - cached_tokens (Optional[int]): Cached tokens present in the prompt. + cached_tokens (Optional[int]): Tokens read from the prompt cache (cache hit). + cache_creation_tokens (Optional[int]): Tokens written to the prompt cache (cache miss). + cache_creation_token_details (Optional[CacheCreationTokenDetails]): Per-TTL + breakdown of cache writes. Present only when an explicit ttl was used. """ audio_tokens: Optional[int] = None cached_tokens: Optional[int] = None + cache_creation_tokens: Optional[int] = None + cache_creation_token_details: Optional[CacheCreationTokenDetails] = None class CompletionTokensDetails(ResponseBaseModel): """ @@ -385,7 +404,8 @@ class OrchestrationResponseWithRetries(CompletionPostResponse): """ retries: int = 0 -__all__ = ["PromptTokensDetails", +__all__ = ["CacheCreationTokenDetails", + "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py index ea4811bf..e17bc105 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py @@ -11,6 +11,7 @@ from pydantic import Field from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel +from gen_ai_hub.orchestration_v2.models.cache_control import CacheControl def python_type_to_json_type(py_type): @@ -71,10 +72,14 @@ class ChatCompletionTool(BaseModel): Args: type (Literal["function"]): The type of the tool. Currently, only function is supported. + + cache_control: Optional cache control settings for prompt caching. + Supported for Anthropic Claude models only. Not supported for Amazon Nova. """ type_: Literal["function"] = Field(default="function", alias="type", description="The type of the tool. Currently, only function is supported.") + cache_control: Optional[CacheControl] = None class FunctionObject(BaseModel): diff --git a/packages/gen/tests/orchestration_v2/test_flat_import.py b/packages/gen/tests/orchestration_v2/test_flat_import.py index 7606ffac..ca877b41 100644 --- a/packages/gen/tests/orchestration_v2/test_flat_import.py +++ b/packages/gen/tests/orchestration_v2/test_flat_import.py @@ -1,4 +1,7 @@ expected = { + # cache_control + "CacheControl", + # azure_content_filter "AzureContentFilter", "AzureContentSafetyInput", "AzureContentSafetyOutput", "AzureThreshold", @@ -46,7 +49,7 @@ "ChatCompletionTokenLogprob", "ChoiceLogprobs", "LLMChoice", "StreamFunctionObject", "StreamToolCall", "StreamDelta", "StreamLLMChoice", "Citation", "LLMModuleResult", "StreamLLMModuleResult", "ModuleResults", "StreamModuleResults", "SAPAPIError", "SAPAPIErrorStreaming", "CompletionPostResponse", - "StreamCompletionPostResponse", "ErrorResponse", "ErrorResponseStreaming", "OrchestrationResponseWithRetries", + "StreamCompletionPostResponse", "ErrorResponse", "ErrorResponseStreaming", "OrchestrationResponseWithRetries", "CacheCreationTokenDetails", # response_format "ResponseFormatText", "ResponseFormatJsonObject", "ResponseFormatJsonSchema", "JSONResponseSchema", @@ -72,8 +75,8 @@ "OrchestrationService", # Exceptions - "OrchestrationError", "OrchestrationErrorList" - } + "OrchestrationError", "OrchestrationErrorList", + } def test_flat_import_all(): From 1854bfcefb17b68ef849947ef8f50c31d80e0b24 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Thu, 10 Sep 2026 15:52:46 +0200 Subject: [PATCH 2/6] e2e tests --- .../orchestration_v2/test_cache_control.py | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 packages/gen/integration_tests/orchestration_v2/test_cache_control.py diff --git a/packages/gen/integration_tests/orchestration_v2/test_cache_control.py b/packages/gen/integration_tests/orchestration_v2/test_cache_control.py new file mode 100644 index 00000000..786d1845 --- /dev/null +++ b/packages/gen/integration_tests/orchestration_v2/test_cache_control.py @@ -0,0 +1,282 @@ +""" +Unit and integration tests for prompt caching (cache_control) via Orchestration V2. + +Caching is supported for Anthropic Claude and Amazon Nova models. +The test targets anthropic--claude-4.6-sonnet (1024-token minimum, 5m and 1h TTLs). + +Wire path: + ai-sdk-python -> SAP AI Core /v2/completion -> SAP LiteLLM fork -> Anthropic API + +cache_control is serialized as a plain JSON key on the content block or tool dict by +Pydantic model_dump(). The SAP LiteLLM fork translates it into Anthropic's native +prompt-caching format. + +The spec defines cache_control on three schema-level attachment points: + - TextContent.cache_control (TextPart in py) + - UserChatMessageContentItem.cache_control (TextPart / ImagePart in py) + - ChatCompletionTool.cache_control + +Response fields (from SAP AI Core orchestration docs): + prompt_tokens_details.cached_tokens -- tokens read from cache (hit) + prompt_tokens_details.cache_creation_tokens -- tokens written to cache (miss) + prompt_tokens_details.cache_creation_token_details.ephemeral_5m_input_tokens + prompt_tokens_details.cache_creation_token_details.ephemeral_1h_input_tokens +""" +import unittest + +from gen_ai_hub.orchestration_v2.models.cache_control import CacheControl +from gen_ai_hub.orchestration_v2.models.config import OrchestrationConfig, ModuleConfig +from gen_ai_hub.orchestration_v2.models.llm_model_details import LLMModelDetails +from gen_ai_hub.orchestration_v2.models.message import SystemMessage, UserMessage +from gen_ai_hub.orchestration_v2.models.multimodal_items import TextPart, ImagePart, ImageUrl +from gen_ai_hub.orchestration_v2.models.template import Template, PromptTemplatingModuleConfig +from gen_ai_hub.orchestration_v2.models.tools import ChatCompletionTool, FunctionTool, FunctionObject +from gen_ai_hub.orchestration_v2.service import OrchestrationService +from integration_tests.orchestration_v2.test_base import OrchestrationServiceTestBase +from integration_tests.test_helpers import retry_on_429_or_503 + +# Must exceed the 1024-token minimum for claude-4.6-sonnet cache points. +_LONG_SYSTEM_PROMPT = ( + "You are a helpful assistant with deep knowledge of European history. " + "Below is a detailed reference text that you must use to answer questions accurately.\n\n" + + ( + "The Roman Empire was one of the largest empires in ancient history. " + "At its height under Emperor Trajan in 117 AD, it covered over 5 million " + "square kilometres and held 70 million people, roughly 21 percent of the " + "world's population at the time. The empire's longevity — nearly five " + "centuries in the west and fifteen in the east — shaped the languages, " + "laws, religions, and borders of modern Europe. Latin evolved into the " + "Romance languages: Italian, Spanish, Portuguese, French, and Romanian. " + "Roman law underlies most continental legal systems today. Christianity, " + "adopted as the state religion under Theodosius I in 380 AD, spread " + "throughout the empire and became the dominant faith of Europe. " + "The fall of the Western Roman Empire in 476 AD, when the Germanic " + "chieftain Odoacer deposed the last emperor Romulus Augustulus, marks " + "the conventional boundary between ancient and medieval history. " + "The Eastern Roman Empire, known as the Byzantine Empire, continued " + "for nearly a thousand more years until the fall of Constantinople to " + "the Ottoman Turks in 1453. Byzantine culture preserved classical Greek " + "and Roman learning through the Dark Ages and transmitted it to the " + "Renaissance. The Silk Road trade routes connecting Rome to China " + "facilitated the exchange of goods, diseases, and ideas across Eurasia. " + "Roman engineering achievements — aqueducts, roads, concrete construction, " + "and underfloor heating — were not equalled in Europe for over a millennium " + "after the empire's fall. The Colosseum, completed in 80 AD, could seat " + "50,000 to 80,000 spectators and hosted gladiatorial contests, animal " + "hunts, and public executions for four centuries. " + ) * 4 # repeat to comfortably exceed 1024 tokens +) + +_LLM = LLMModelDetails( + name="anthropic--claude-4.6-sonnet", + params={"max_tokens": 64, "temperature": 0.0}, +) + + +def _config(messages, tools=None): + return OrchestrationConfig( + modules=ModuleConfig( + prompt_templating=PromptTemplatingModuleConfig( + prompt=Template(template=messages, tools=tools), + model=_LLM, + ) + ) + ) + + +class TestCacheControlSerialization(unittest.TestCase): + """Unit tests: verify cache_control serializes correctly without a network call.""" + + # ------------------------------------------------------------------ + # CacheControl model + # ------------------------------------------------------------------ + + def test_cache_control_default_ttl_omits_key(self): + """CacheControl() with no TTL serializes to {"type": "ephemeral"}.""" + d = CacheControl().model_dump(by_alias=True) + self.assertEqual(d, {"type": "ephemeral"}) + self.assertNotIn("ttl", d) + + def test_cache_control_5m_ttl(self): + """CacheControl(ttl="5m") serializes with ttl field.""" + d = CacheControl(ttl="5m").model_dump(by_alias=True) + self.assertEqual(d, {"type": "ephemeral", "ttl": "5m"}) + + def test_cache_control_1h_ttl(self): + """CacheControl(ttl="1h") serializes with ttl field.""" + d = CacheControl(ttl="1h").model_dump(by_alias=True) + self.assertEqual(d, {"type": "ephemeral", "ttl": "1h"}) + + # ------------------------------------------------------------------ + # TextPart with cache_control + # ------------------------------------------------------------------ + + def test_text_part_with_cache_control(self): + """TextPart with cache_control serializes the cache_control block.""" + part = TextPart(text="hello", cache_control=CacheControl()) + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["type"], "text") + self.assertEqual(d["text"], "hello") + self.assertEqual(d["cache_control"], {"type": "ephemeral"}) + + def test_text_part_without_cache_control_omits_key(self): + """TextPart without cache_control does not emit the key.""" + part = TextPart(text="hello") + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertNotIn("cache_control", d) + + def test_text_part_with_1h_ttl(self): + """TextPart with CacheControl(ttl='1h') serializes the ttl field.""" + part = TextPart(text="hello", cache_control=CacheControl(ttl="1h")) + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) + + # ------------------------------------------------------------------ + # ImagePart with cache_control + # ------------------------------------------------------------------ + + def test_image_part_with_cache_control(self): + """ImagePart with cache_control serializes the cache_control block.""" + part = ImagePart( + image_url=ImageUrl(url="https://example.com/img.png"), + cache_control=CacheControl(), + ) + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["type"], "image_url") + self.assertEqual(d["cache_control"], {"type": "ephemeral"}) + + def test_image_part_without_cache_control_omits_key(self): + """ImagePart without cache_control does not emit the key.""" + part = ImagePart(image_url=ImageUrl(url="https://example.com/img.png")) + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertNotIn("cache_control", d) + + def test_image_part_with_1h_ttl(self): + """ImagePart with CacheControl(ttl='1h') serializes the ttl field.""" + part = ImagePart( + image_url=ImageUrl(url="https://example.com/img.png"), + cache_control=CacheControl(ttl="1h"), + ) + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) + + # ------------------------------------------------------------------ + # ChatCompletionTool with cache_control + # ------------------------------------------------------------------ + + def test_tool_cache_control_serialized(self): + """cache_control on a ChatCompletionTool appears at the tool level.""" + tool = FunctionTool( + function=FunctionObject( + name="classify", + description="Classify input.", + parameters={"type": "object", "properties": {}}, + ), + cache_control=CacheControl(), + ) + d = tool.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral"}) + + def test_tool_without_cache_control_omits_key(self): + """A tool without cache_control does not emit the key.""" + tool = FunctionTool( + function=FunctionObject( + name="classify", + description="Classify input.", + parameters={"type": "object", "properties": {}}, + ), + ) + d = tool.model_dump(by_alias=True, exclude_none=True) + self.assertNotIn("cache_control", d) + + def test_tool_with_1h_ttl(self): + """cache_control with ttl='1h' on a tool serializes the ttl field.""" + tool = FunctionTool( + function=FunctionObject( + name="classify", + description="Classify input.", + parameters={"type": "object", "properties": {}}, + ), + cache_control=CacheControl(ttl="1h"), + ) + d = tool.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) + + +class TestPromptCachingLive(OrchestrationServiceTestBase): + """Live integration tests against the SAP AI Core orchestration V2 service.""" + + def setUp(self): + super().setUp() + self.service = OrchestrationService(self.api_url) + + # ------------------------------------------------------------------ + # 1. Cache MISS on first call — cache breakpoint on TextPart directly + # ------------------------------------------------------------------ + + @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) + def test_cache_miss_on_first_call(self): + """First call with cache_control on the last TextPart block returns + non-zero cache token activity. + + cache_creation_tokens > 0 on a true miss; cached_tokens > 0 when the + cache entry is already warm from a previous run. Either proves the + cache_control breakpoint was accepted by the server. + """ + config = _config([ + SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl())]), + UserMessage(content="In one word: what language did Romans speak?"), + ]) + response = self.service.run(config=config) + details = response.final_result.usage.prompt_tokens_details + self.assertIsNotNone(details) + cache_active = (details.cache_creation_tokens or 0) + (details.cached_tokens or 0) + self.assertGreater( + cache_active, 0, + f"Expected cache activity (cache_creation_tokens or cached_tokens > 0), " + f"got: {details}", + ) + + # ------------------------------------------------------------------ + # 2. Cache HIT on repeated call + # ------------------------------------------------------------------ + + @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) + def test_cache_hit_on_repeated_call(self): + """Second call with the same cache breakpoint produces cached_tokens > 0.""" + config = _config([ + SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl())]), + UserMessage(content="In one word: what language did Romans speak?"), + ]) + self.service.run(config=config) # populate cache + response = self.service.run(config=config) + details = response.final_result.usage.prompt_tokens_details + self.assertIsNotNone(details) + self.assertGreater( + details.cached_tokens, 0, + "Expected cached_tokens > 0 on second call (cache hit).", + ) + + # ------------------------------------------------------------------ + # 3. Explicit 1h TTL via TextPart + # ------------------------------------------------------------------ + + @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) + def test_explicit_ttl_1h_via_text_part(self): + """Attaching CacheControl(ttl='1h') directly to a TextPart returns + cache_creation_token_details with ephemeral_1h_input_tokens.""" + config = _config([ + SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl(ttl="1h"))]), + UserMessage(content="Name the last Western Roman emperor."), + ]) + response = self.service.run(config=config) + details = response.final_result.usage.prompt_tokens_details + self.assertIsNotNone(details) + self.assertIsNotNone( + details.cache_creation_token_details, + "Expected cache_creation_token_details when ttl='1h' is used.", + ) + + +if __name__ == "__main__": + unittest.main() From e16cbf6d1960fd76582d7a3c2887af1f151e2745 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Thu, 10 Sep 2026 18:14:42 +0200 Subject: [PATCH 3/6] tests fixed and rmd duplicate class attribute --- .../orchestration_v2/models/tools.py | 41 ++---- .../orchestration_v2/test_cache_control.py | 135 +----------------- .../orchestration_v2/test_cache_control_v2.py | 117 +++++++++++++++ 3 files changed, 132 insertions(+), 161 deletions(-) create mode 100644 packages/gen/tests/orchestration_v2/test_cache_control_v2.py diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py index e17bc105..b8ef1979 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py @@ -67,37 +67,24 @@ def python_type_to_json_type(py_type): class ChatCompletionTool(BaseModel): - """ - Base class for all chat completion tools. + """A tool the model may call, identified by type ``"function"``. Args: - type (Literal["function"]): The type of the tool. Currently, only function is supported. - - cache_control: Optional cache control settings for prompt caching. - Supported for Anthropic Claude models only. Not supported for Amazon Nova. + cache_control: Prompt-caching directive. Supported on Anthropic Claude only; + not supported on Amazon Nova. """ - type_: Literal["function"] = Field(default="function", - alias="type", - description="The type of the tool. Currently, only function is supported.") + type_: Literal["function"] = Field(default="function", alias="type") cache_control: Optional[CacheControl] = None class FunctionObject(BaseModel): - """ - Represents a function. - Args: - name (str): The name of the function to be called. Must be a-z, A-Z, 0-9, - or contain underscores and dashes, with a maximum length of 64. - - description (str): A description of what the function does, used by the model - to choose when and how to call the function. + """A function definition used inside a ``FunctionTool``. - parameters (dict): The parameters the functions accepts, described as a JSON Schema object. - Omitting parameters defines a function with an empty parameter list. - - strict (bool, optional): Whether to enable strict schema adherence when generating the function call. - If set to true, the model will follow the exact schema defined in the parameters field. - Only a subset of JSON Schema is supported when strict is true. Defaults to False. + Args: + name: Function name. Must match ``^[a-zA-Z0-9_-]+$``, max 64 chars. + description: What the function does; used by the model to decide when to call it. + parameters: JSON Schema object describing accepted parameters. + strict: When ``True``, the model follows the schema exactly. Defaults to ``False``. """ description: Optional[str] = None name: str @@ -107,15 +94,11 @@ class FunctionObject(BaseModel): class FunctionTool(ChatCompletionTool): - """ - Represents a function tool for OpenAI-like function calling. + """A callable function tool for OpenAI-style function calling. Args: - type (Literal["function"]): The type of the tool. Currently, only function is supported. - - function (FunctionObject): The function to be called. + function: The function definition (name, description, parameters). """ - type_: Literal["function"] = Field(default="function", alias="type") function: FunctionObject def execute(self, **kwargs: Any) -> Any: diff --git a/packages/gen/integration_tests/orchestration_v2/test_cache_control.py b/packages/gen/integration_tests/orchestration_v2/test_cache_control.py index 786d1845..c9b000b8 100644 --- a/packages/gen/integration_tests/orchestration_v2/test_cache_control.py +++ b/packages/gen/integration_tests/orchestration_v2/test_cache_control.py @@ -1,21 +1,12 @@ """ -Unit and integration tests for prompt caching (cache_control) via Orchestration V2. +Live integration tests for prompt caching (cache_control) via Orchestration V2. Caching is supported for Anthropic Claude and Amazon Nova models. -The test targets anthropic--claude-4.6-sonnet (1024-token minimum, 5m and 1h TTLs). +The tests target anthropic--claude-4.6-sonnet (1024-token minimum, 5m and 1h TTLs). Wire path: ai-sdk-python -> SAP AI Core /v2/completion -> SAP LiteLLM fork -> Anthropic API -cache_control is serialized as a plain JSON key on the content block or tool dict by -Pydantic model_dump(). The SAP LiteLLM fork translates it into Anthropic's native -prompt-caching format. - -The spec defines cache_control on three schema-level attachment points: - - TextContent.cache_control (TextPart in py) - - UserChatMessageContentItem.cache_control (TextPart / ImagePart in py) - - ChatCompletionTool.cache_control - Response fields (from SAP AI Core orchestration docs): prompt_tokens_details.cached_tokens -- tokens read from cache (hit) prompt_tokens_details.cache_creation_tokens -- tokens written to cache (miss) @@ -28,9 +19,8 @@ from gen_ai_hub.orchestration_v2.models.config import OrchestrationConfig, ModuleConfig from gen_ai_hub.orchestration_v2.models.llm_model_details import LLMModelDetails from gen_ai_hub.orchestration_v2.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration_v2.models.multimodal_items import TextPart, ImagePart, ImageUrl +from gen_ai_hub.orchestration_v2.models.multimodal_items import TextPart from gen_ai_hub.orchestration_v2.models.template import Template, PromptTemplatingModuleConfig -from gen_ai_hub.orchestration_v2.models.tools import ChatCompletionTool, FunctionTool, FunctionObject from gen_ai_hub.orchestration_v2.service import OrchestrationService from integration_tests.orchestration_v2.test_base import OrchestrationServiceTestBase from integration_tests.test_helpers import retry_on_429_or_503 @@ -84,125 +74,6 @@ def _config(messages, tools=None): ) -class TestCacheControlSerialization(unittest.TestCase): - """Unit tests: verify cache_control serializes correctly without a network call.""" - - # ------------------------------------------------------------------ - # CacheControl model - # ------------------------------------------------------------------ - - def test_cache_control_default_ttl_omits_key(self): - """CacheControl() with no TTL serializes to {"type": "ephemeral"}.""" - d = CacheControl().model_dump(by_alias=True) - self.assertEqual(d, {"type": "ephemeral"}) - self.assertNotIn("ttl", d) - - def test_cache_control_5m_ttl(self): - """CacheControl(ttl="5m") serializes with ttl field.""" - d = CacheControl(ttl="5m").model_dump(by_alias=True) - self.assertEqual(d, {"type": "ephemeral", "ttl": "5m"}) - - def test_cache_control_1h_ttl(self): - """CacheControl(ttl="1h") serializes with ttl field.""" - d = CacheControl(ttl="1h").model_dump(by_alias=True) - self.assertEqual(d, {"type": "ephemeral", "ttl": "1h"}) - - # ------------------------------------------------------------------ - # TextPart with cache_control - # ------------------------------------------------------------------ - - def test_text_part_with_cache_control(self): - """TextPart with cache_control serializes the cache_control block.""" - part = TextPart(text="hello", cache_control=CacheControl()) - d = part.model_dump(by_alias=True, exclude_none=True) - self.assertEqual(d["type"], "text") - self.assertEqual(d["text"], "hello") - self.assertEqual(d["cache_control"], {"type": "ephemeral"}) - - def test_text_part_without_cache_control_omits_key(self): - """TextPart without cache_control does not emit the key.""" - part = TextPart(text="hello") - d = part.model_dump(by_alias=True, exclude_none=True) - self.assertNotIn("cache_control", d) - - def test_text_part_with_1h_ttl(self): - """TextPart with CacheControl(ttl='1h') serializes the ttl field.""" - part = TextPart(text="hello", cache_control=CacheControl(ttl="1h")) - d = part.model_dump(by_alias=True, exclude_none=True) - self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) - - # ------------------------------------------------------------------ - # ImagePart with cache_control - # ------------------------------------------------------------------ - - def test_image_part_with_cache_control(self): - """ImagePart with cache_control serializes the cache_control block.""" - part = ImagePart( - image_url=ImageUrl(url="https://example.com/img.png"), - cache_control=CacheControl(), - ) - d = part.model_dump(by_alias=True, exclude_none=True) - self.assertEqual(d["type"], "image_url") - self.assertEqual(d["cache_control"], {"type": "ephemeral"}) - - def test_image_part_without_cache_control_omits_key(self): - """ImagePart without cache_control does not emit the key.""" - part = ImagePart(image_url=ImageUrl(url="https://example.com/img.png")) - d = part.model_dump(by_alias=True, exclude_none=True) - self.assertNotIn("cache_control", d) - - def test_image_part_with_1h_ttl(self): - """ImagePart with CacheControl(ttl='1h') serializes the ttl field.""" - part = ImagePart( - image_url=ImageUrl(url="https://example.com/img.png"), - cache_control=CacheControl(ttl="1h"), - ) - d = part.model_dump(by_alias=True, exclude_none=True) - self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) - - # ------------------------------------------------------------------ - # ChatCompletionTool with cache_control - # ------------------------------------------------------------------ - - def test_tool_cache_control_serialized(self): - """cache_control on a ChatCompletionTool appears at the tool level.""" - tool = FunctionTool( - function=FunctionObject( - name="classify", - description="Classify input.", - parameters={"type": "object", "properties": {}}, - ), - cache_control=CacheControl(), - ) - d = tool.model_dump(by_alias=True, exclude_none=True) - self.assertEqual(d["cache_control"], {"type": "ephemeral"}) - - def test_tool_without_cache_control_omits_key(self): - """A tool without cache_control does not emit the key.""" - tool = FunctionTool( - function=FunctionObject( - name="classify", - description="Classify input.", - parameters={"type": "object", "properties": {}}, - ), - ) - d = tool.model_dump(by_alias=True, exclude_none=True) - self.assertNotIn("cache_control", d) - - def test_tool_with_1h_ttl(self): - """cache_control with ttl='1h' on a tool serializes the ttl field.""" - tool = FunctionTool( - function=FunctionObject( - name="classify", - description="Classify input.", - parameters={"type": "object", "properties": {}}, - ), - cache_control=CacheControl(ttl="1h"), - ) - d = tool.model_dump(by_alias=True, exclude_none=True) - self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) - - class TestPromptCachingLive(OrchestrationServiceTestBase): """Live integration tests against the SAP AI Core orchestration V2 service.""" diff --git a/packages/gen/tests/orchestration_v2/test_cache_control_v2.py b/packages/gen/tests/orchestration_v2/test_cache_control_v2.py new file mode 100644 index 00000000..70830eb0 --- /dev/null +++ b/packages/gen/tests/orchestration_v2/test_cache_control_v2.py @@ -0,0 +1,117 @@ +""" +Unit tests for prompt-caching serialization (cache_control) in Orchestration V2. + +Covers the three spec attachment points: + - TextContent.cache_control (TextPart) + - UserChatMessageContentItem (TextPart / ImagePart) + - ChatCompletionTool.cache_control +""" +import unittest + +from gen_ai_hub.orchestration_v2.models.cache_control import CacheControl +from gen_ai_hub.orchestration_v2.models.multimodal_items import TextPart, ImagePart, ImageUrl +from gen_ai_hub.orchestration_v2.models.tools import FunctionTool, FunctionObject + + +class TestCacheControlModel(unittest.TestCase): + """CacheControl serialization.""" + + def test_default_ttl_omits_key(self): + """CacheControl() with no TTL serializes to {"type": "ephemeral"}.""" + d = CacheControl().model_dump(by_alias=True) + self.assertEqual(d, {"type": "ephemeral"}) + self.assertNotIn("ttl", d) + + def test_5m_ttl(self): + d = CacheControl(ttl="5m").model_dump(by_alias=True) + self.assertEqual(d, {"type": "ephemeral", "ttl": "5m"}) + + def test_1h_ttl(self): + d = CacheControl(ttl="1h").model_dump(by_alias=True) + self.assertEqual(d, {"type": "ephemeral", "ttl": "1h"}) + + +class TestTextPartCacheControl(unittest.TestCase): + """TextPart.cache_control serialization.""" + + def test_with_cache_control(self): + part = TextPart(text="hello", cache_control=CacheControl()) + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["type"], "text") + self.assertEqual(d["text"], "hello") + self.assertEqual(d["cache_control"], {"type": "ephemeral"}) + + def test_without_cache_control_omits_key(self): + part = TextPart(text="hello") + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertNotIn("cache_control", d) + + def test_1h_ttl(self): + part = TextPart(text="hello", cache_control=CacheControl(ttl="1h")) + d = part.model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) + + +class TestImagePartCacheControl(unittest.TestCase): + """ImagePart.cache_control serialization.""" + + def _image_part(self, **kwargs): + return ImagePart(image_url=ImageUrl(url="https://example.com/img.png"), **kwargs) + + def test_with_cache_control(self): + d = self._image_part(cache_control=CacheControl()).model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["type"], "image_url") + self.assertEqual(d["cache_control"], {"type": "ephemeral"}) + + def test_without_cache_control_omits_key(self): + d = self._image_part().model_dump(by_alias=True, exclude_none=True) + self.assertNotIn("cache_control", d) + + def test_1h_ttl(self): + d = self._image_part(cache_control=CacheControl(ttl="1h")).model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) + + +class TestFunctionToolCacheControl(unittest.TestCase): + """ChatCompletionTool.cache_control serialization via FunctionTool.""" + + def _tool(self, **kwargs): + return FunctionTool( + function=FunctionObject( + name="classify", + description="Classify input.", + parameters={"type": "object", "properties": {}}, + ), + **kwargs, + ) + + def test_with_cache_control(self): + d = self._tool(cache_control=CacheControl()).model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral"}) + + def test_without_cache_control_omits_key(self): + d = self._tool().model_dump(by_alias=True, exclude_none=True) + self.assertNotIn("cache_control", d) + + def test_1h_ttl(self): + d = self._tool(cache_control=CacheControl(ttl="1h")).model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) + + def test_type_field_serializes(self): + """type_ with alias 'type' must appear in output.""" + d = self._tool().model_dump(by_alias=True, exclude_none=True) + self.assertEqual(d["type"], "function") + + def test_no_duplicate_type_from_subclass(self): + """FunctionTool must not declare its own type_ field (inherits from ChatCompletionTool).""" + import inspect + own_fields = FunctionTool.model_fields + # 'type_' is defined on ChatCompletionTool; FunctionTool should only add 'function' + self.assertIn("function", own_fields) + # Ensure serialization is still correct (regression guard) + d = self._tool().model_dump(by_alias=True) + self.assertEqual(d["type"], "function") + + +if __name__ == "__main__": + unittest.main() From 39d9000d043e7aa67d9f0804851a47fac0598d77 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Thu, 10 Sep 2026 18:19:53 +0200 Subject: [PATCH 4/6] docstring fixed --- .../gen/gen_ai_hub/orchestration_v2/models/tools.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py index b8ef1979..4ac6be73 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py @@ -96,8 +96,17 @@ class FunctionObject(BaseModel): class FunctionTool(ChatCompletionTool): """A callable function tool for OpenAI-style function calling. + Inherits all fields from :class:`ChatCompletionTool`: + Args: - function: The function definition (name, description, parameters). + type: Always ``"function"``. Serialized via the ``type`` alias. + cache_control: Prompt-caching directive. Supported on Anthropic Claude only; + not supported on Amazon Nova. + + Additional args: + + function: The function definition — name, description, parameters schema, + and optional strict flag. See :class:`FunctionObject`. """ function: FunctionObject From e0a040be710cc9cd76a93604b0cfd538aaf1f170 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Fri, 11 Sep 2026 15:29:27 +0200 Subject: [PATCH 5/6] remove default ephemeral, fix docstrings, consistent quoting --- .../orchestration_v2/models/cache_control.py | 18 ++++------------- .../orchestration_v2/test_cache_control.py | 8 ++++---- .../orchestration_v2/test_cache_control_v2.py | 20 +++++++++---------- 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py b/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py index 348d7d95..1d9f8a90 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py @@ -18,25 +18,15 @@ class CacheControl(BaseModel): - Amazon Nova: system and user content blocks only (no tools, no TTL). Attach ``CacheControl`` directly to a content block (``TextPart``, ``ImagePart``) or - to a ``ChatCompletionTool``. For the "last-block shorthand" pass ``cache_control`` to - ``OrchestrationService.run()``; it calls ``apply_cache_control_to_last_message()`` - automatically. - + to a ``ChatCompletionTool``. + Args: - type: Always ``"ephemeral"``. Only value supported by the API. + type: ``"ephemeral"`` ttl: Cache duration. ``"5m"`` (default) or ``"1h"`` (select Anthropic models only). Omit for Amazon Nova or when the default is sufficient. - - Example:: - - from gen_ai_hub.orchestration_v2.models.cache_control import CacheControl - from gen_ai_hub.orchestration_v2.models.multimodal_items import TextPart - - block = TextPart(text="Long context...", cache_control=CacheControl()) - block_1h = TextPart(text="Long context...", cache_control=CacheControl(ttl="1h")) """ - type: Literal["ephemeral"] = "ephemeral" + type: Literal["ephemeral"] ttl: Optional[Literal["5m", "1h"]] = None @model_serializer(mode="wrap") diff --git a/packages/gen/integration_tests/orchestration_v2/test_cache_control.py b/packages/gen/integration_tests/orchestration_v2/test_cache_control.py index c9b000b8..c6ff8eeb 100644 --- a/packages/gen/integration_tests/orchestration_v2/test_cache_control.py +++ b/packages/gen/integration_tests/orchestration_v2/test_cache_control.py @@ -95,7 +95,7 @@ def test_cache_miss_on_first_call(self): cache_control breakpoint was accepted by the server. """ config = _config([ - SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl())]), + SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl(type="ephemeral"))]), UserMessage(content="In one word: what language did Romans speak?"), ]) response = self.service.run(config=config) @@ -116,7 +116,7 @@ def test_cache_miss_on_first_call(self): def test_cache_hit_on_repeated_call(self): """Second call with the same cache breakpoint produces cached_tokens > 0.""" config = _config([ - SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl())]), + SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl(type="ephemeral"))]), UserMessage(content="In one word: what language did Romans speak?"), ]) self.service.run(config=config) # populate cache @@ -134,10 +134,10 @@ def test_cache_hit_on_repeated_call(self): @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) def test_explicit_ttl_1h_via_text_part(self): - """Attaching CacheControl(ttl='1h') directly to a TextPart returns + """Attaching CacheControl(type='ephemeral', ttl='1h') directly to a TextPart returns cache_creation_token_details with ephemeral_1h_input_tokens.""" config = _config([ - SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl(ttl="1h"))]), + SystemMessage(content=[TextPart(text=_LONG_SYSTEM_PROMPT, cache_control=CacheControl(type='ephemeral', ttl='1h'))]), UserMessage(content="Name the last Western Roman emperor."), ]) response = self.service.run(config=config) diff --git a/packages/gen/tests/orchestration_v2/test_cache_control_v2.py b/packages/gen/tests/orchestration_v2/test_cache_control_v2.py index 70830eb0..adadf266 100644 --- a/packages/gen/tests/orchestration_v2/test_cache_control_v2.py +++ b/packages/gen/tests/orchestration_v2/test_cache_control_v2.py @@ -17,17 +17,17 @@ class TestCacheControlModel(unittest.TestCase): """CacheControl serialization.""" def test_default_ttl_omits_key(self): - """CacheControl() with no TTL serializes to {"type": "ephemeral"}.""" - d = CacheControl().model_dump(by_alias=True) + """CacheControl(type='ephemeral') with no TTL serializes to {"type": "ephemeral"}.""" + d = CacheControl(type="ephemeral").model_dump(by_alias=True) self.assertEqual(d, {"type": "ephemeral"}) self.assertNotIn("ttl", d) def test_5m_ttl(self): - d = CacheControl(ttl="5m").model_dump(by_alias=True) + d = CacheControl(type="ephemeral", ttl="5m").model_dump(by_alias=True) self.assertEqual(d, {"type": "ephemeral", "ttl": "5m"}) def test_1h_ttl(self): - d = CacheControl(ttl="1h").model_dump(by_alias=True) + d = CacheControl(type="ephemeral", ttl="1h").model_dump(by_alias=True) self.assertEqual(d, {"type": "ephemeral", "ttl": "1h"}) @@ -35,7 +35,7 @@ class TestTextPartCacheControl(unittest.TestCase): """TextPart.cache_control serialization.""" def test_with_cache_control(self): - part = TextPart(text="hello", cache_control=CacheControl()) + part = TextPart(text="hello", cache_control=CacheControl(type="ephemeral")) d = part.model_dump(by_alias=True, exclude_none=True) self.assertEqual(d["type"], "text") self.assertEqual(d["text"], "hello") @@ -47,7 +47,7 @@ def test_without_cache_control_omits_key(self): self.assertNotIn("cache_control", d) def test_1h_ttl(self): - part = TextPart(text="hello", cache_control=CacheControl(ttl="1h")) + part = TextPart(text="hello", cache_control=CacheControl(type="ephemeral", ttl="1h")) d = part.model_dump(by_alias=True, exclude_none=True) self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) @@ -59,7 +59,7 @@ def _image_part(self, **kwargs): return ImagePart(image_url=ImageUrl(url="https://example.com/img.png"), **kwargs) def test_with_cache_control(self): - d = self._image_part(cache_control=CacheControl()).model_dump(by_alias=True, exclude_none=True) + d = self._image_part(cache_control=CacheControl(type="ephemeral")).model_dump(by_alias=True, exclude_none=True) self.assertEqual(d["type"], "image_url") self.assertEqual(d["cache_control"], {"type": "ephemeral"}) @@ -68,7 +68,7 @@ def test_without_cache_control_omits_key(self): self.assertNotIn("cache_control", d) def test_1h_ttl(self): - d = self._image_part(cache_control=CacheControl(ttl="1h")).model_dump(by_alias=True, exclude_none=True) + d = self._image_part(cache_control=CacheControl(type="ephemeral", ttl="1h")).model_dump(by_alias=True, exclude_none=True) self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) @@ -86,7 +86,7 @@ def _tool(self, **kwargs): ) def test_with_cache_control(self): - d = self._tool(cache_control=CacheControl()).model_dump(by_alias=True, exclude_none=True) + d = self._tool(cache_control=CacheControl(type="ephemeral")).model_dump(by_alias=True, exclude_none=True) self.assertEqual(d["cache_control"], {"type": "ephemeral"}) def test_without_cache_control_omits_key(self): @@ -94,7 +94,7 @@ def test_without_cache_control_omits_key(self): self.assertNotIn("cache_control", d) def test_1h_ttl(self): - d = self._tool(cache_control=CacheControl(ttl="1h")).model_dump(by_alias=True, exclude_none=True) + d = self._tool(cache_control=CacheControl(type="ephemeral", ttl="1h")).model_dump(by_alias=True, exclude_none=True) self.assertEqual(d["cache_control"], {"type": "ephemeral", "ttl": "1h"}) def test_type_field_serializes(self): From 48f4a6c28e9a48c3df2f23333d5ba94bbc1064c4 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Tue, 15 Sep 2026 15:14:37 +0200 Subject: [PATCH 6/6] fix: docstring fixes and Field(exclude=False) to None --- .../gen_ai_hub/orchestration_v2/models/cache_control.py | 6 +----- .../gen_ai_hub/orchestration_v2/models/multimodal_items.py | 6 ++---- packages/gen/gen_ai_hub/orchestration_v2/models/tools.py | 7 +++---- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py b/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py index 1d9f8a90..2d232c3f 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/cache_control.py @@ -1,4 +1,4 @@ -"""Cache control for prompt caching on supported Anthropic and Amazon Nova models.""" +"""Cache control for prompt caching on supported models.""" from typing import Any, Dict, Literal, Optional from pydantic import model_serializer @@ -13,10 +13,6 @@ class CacheControl(BaseModel): results for that content and reuses them on subsequent requests within the TTL window, reducing both latency and token costs. - Supported models: - - Anthropic Claude: system and user content blocks; tools. - - Amazon Nova: system and user content blocks only (no tools, no TTL). - Attach ``CacheControl`` directly to a content block (``TextPart``, ``ImagePart``) or to a ``ChatCompletionTool``. diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py b/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py index 9f17c893..2f46ed71 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py @@ -41,11 +41,10 @@ class TextPart(BaseModel): type: The type identifier, defaulting to "text". cache_control: Optional cache control settings for prompt caching. - Only supported for Anthropic Claude and Amazon Nova models. """ text: str type_: Literal["text"] = Field(default="text", alias="type") - cache_control: Optional[CacheControl] = Field(default=None, exclude=False) + cache_control: Optional[CacheControl] = None class ImageUrl(BaseModel): @@ -71,11 +70,10 @@ class ImagePart(BaseModel): type: The type identifier, defaulting to "image_url". cache_control: Optional cache control settings for prompt caching. - Only supported for Anthropic Claude models. """ image_url: ImageUrl type_: Literal["image_url"] = Field(default="image_url", alias="type") - cache_control: Optional[CacheControl] = Field(default=None, exclude=False) + cache_control: Optional[CacheControl] = None ContentPart = Union[TextPart, ImagePart] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py index 4ac6be73..9177b02a 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/tools.py @@ -70,8 +70,8 @@ class ChatCompletionTool(BaseModel): """A tool the model may call, identified by type ``"function"``. Args: - cache_control: Prompt-caching directive. Supported on Anthropic Claude only; - not supported on Amazon Nova. + type (Literal["function"]): The type of the tool. Currently, only function is supported. + cache_control: Prompt-caching directive. """ type_: Literal["function"] = Field(default="function", alias="type") cache_control: Optional[CacheControl] = None @@ -100,8 +100,7 @@ class FunctionTool(ChatCompletionTool): Args: type: Always ``"function"``. Serialized via the ``type`` alias. - cache_control: Prompt-caching directive. Supported on Anthropic Claude only; - not supported on Amazon Nova. + cache_control: Prompt-caching directive. Additional args: