From 95cd27181d237ad49880db465d77ee9187755543 Mon Sep 17 00:00:00 2001 From: Mordris <118046112+Mordris@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:57:25 +0300 Subject: [PATCH 1/6] Python: Support prompt cache breakpoints for GPT-5.6 models in OpenAI clients Add request-level prompt_cache_options to OpenAIChatOptions and OpenAIChatCompletionOptions, and forward a per-part prompt_cache_breakpoint from Content.additional_properties onto the content blocks each API supports. Text parts that carry a breakpoint keep typed list content, since the plain-string form cannot hold one; without a breakpoint the existing string forms are unchanged. --- .../agent_framework_openai/_chat_client.py | 23 ++++-- .../_chat_completion_client.py | 71 ++++++++++++++----- .../openai/agent_framework_openai/_shared.py | 36 +++++++++- .../tests/openai/test_openai_chat_client.py | 68 ++++++++++++++++++ .../test_openai_chat_completion_client.py | 71 +++++++++++++++++++ 5 files changed, 245 insertions(+), 24 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index de703509da9..4cdc89a896c 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -88,6 +88,8 @@ from ._exceptions import OpenAIContentFilterException from ._shared import ( AzureTokenProvider, + PromptCacheOptions, + attach_prompt_cache_breakpoint, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -232,6 +234,12 @@ class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], prompt_cache_retention: Literal["24h"] """Retention policy for prompt cache. Set to '24h' for extended caching.""" + prompt_cache_options: PromptCacheOptions + """Request-wide prompt cache policy for GPT-5.6 and later models. + Set mode to 'explicit' to use only the breakpoints set on content parts via + ``Content.additional_properties["prompt_cache_breakpoint"]``. + See: https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints""" + reasoning: ReasoningOptions """Configuration for reasoning models (gpt-5, o-series). See: https://platform.openai.com/docs/guides/reasoning""" @@ -1655,10 +1663,13 @@ def _prepare_content_for_openai( "text": content.text, "annotations": _annotations_to_output_text(getattr(content, "annotations", None)), } - return { - "type": "input_text", - "text": content.text, - } + return attach_prompt_cache_breakpoint( + { + "type": "input_text", + "text": content.text, + }, + content, + ) case "text_reasoning": ret: dict[str, Any] = {"type": "reasoning", "summary": []} if content.id: @@ -1686,7 +1697,7 @@ def _prepare_content_for_openai( file_id = content.additional_properties.get("file_id") if content.additional_properties else None if file_id is not None: result["file_id"] = file_id - return result + return attach_prompt_cache_breakpoint(result, content) if content.has_top_level_media_type("audio"): if content.media_type and "wav" in content.media_type: format = "wav" @@ -1714,7 +1725,7 @@ def _prepare_content_for_openai( } if filename: file_obj["filename"] = filename - return file_obj + return attach_prompt_cache_breakpoint(file_obj, content) return {} case "function_call": if not content.call_id: diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 57f74bfdc71..9905cb8e70c 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -59,7 +59,10 @@ from ._exceptions import OpenAIContentFilterException from ._shared import ( + PROMPT_CACHE_BREAKPOINT_KEY, AzureTokenProvider, + PromptCacheOptions, + attach_prompt_cache_breakpoint, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -157,6 +160,12 @@ class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseM """Output verbosity for GPT-5 family models. Lower values yield shorter responses. See: https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter""" + prompt_cache_options: PromptCacheOptions + """Request-wide prompt cache policy for GPT-5.6 and later models. + Set mode to 'explicit' to use only the breakpoints set on content parts via + ``Content.additional_properties["prompt_cache_breakpoint"]``. + See: https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints""" + OpenAIChatCompletionOptionsT = TypeVar( "OpenAIChatCompletionOptionsT", @@ -876,9 +885,19 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: # System/developer messages must use plain string content because some # OpenAI-compatible endpoints reject list content for non-user roles. if message.role in ("system", "developer"): - texts = [content.text for content in message.contents if content.type == "text" and content.text] - if texts: - sys_args: dict[str, Any] = {"role": message.role, "content": "\n".join(texts)} + text_contents = [content for content in message.contents if content.type == "text" and content.text] + if text_contents: + # A prompt cache breakpoint lives on a typed part; plain-string content + # cannot carry it, so keep the typed list form when one is present. + content_value: str | list[dict[str, Any]] + if any( + (content.additional_properties or {}).get(PROMPT_CACHE_BREAKPOINT_KEY) is not None + for content in text_contents + ): + content_value = [self._prepare_content_for_openai(content) for content in text_contents] + else: + content_value = "\n".join(content.text for content in text_contents if content.text) + sys_args: dict[str, Any] = {"role": message.role, "content": content_value} if message.author_name: sys_args["name"] = message.author_name return [sys_args] @@ -969,6 +988,10 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: text_item = cast(Mapping[str, Any], item) if text_item.get("type") != "text": break + if PROMPT_CACHE_BREAKPOINT_KEY in text_item: + # A plain string cannot carry a prompt cache breakpoint; + # keep the typed part form for this message. + break text_items.append(text_item) else: msg["content"] = "\n".join( @@ -981,6 +1004,11 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: """Prepare content for OpenAI.""" match content.type: + case "text": + return attach_prompt_cache_breakpoint( + {"type": "text", "text": content.text}, + content, + ) case "function_call": args = json.dumps(content.arguments) if isinstance(content.arguments, Mapping) else content.arguments return { @@ -998,10 +1026,13 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: detail = content.additional_properties.get("detail") if isinstance(detail, str): image_url_obj["detail"] = detail - return { - "type": "image_url", - "image_url": image_url_obj, - } + return attach_prompt_cache_breakpoint( + { + "type": "image_url", + "image_url": image_url_obj, + }, + content, + ) case "data" | "uri" if content.has_top_level_media_type("audio"): if content.media_type and "wav" in content.media_type: audio_format = "wav" @@ -1017,13 +1048,16 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: # Extract just the base64 part after "data:audio/format;base64," audio_data = audio_data.split(",", 1)[-1] # type: ignore[union-attr] - return { - "type": "input_audio", - "input_audio": { - "data": audio_data, - "format": audio_format, + return attach_prompt_cache_breakpoint( + { + "type": "input_audio", + "input_audio": { + "data": audio_data, + "format": audio_format, + }, }, - } + content, + ) case "data" | "uri" if content.has_top_level_media_type("application") and content.uri.startswith("data:"): # type: ignore[union-attr] # All application/* media types should be treated as files for OpenAI filename = getattr(content, "filename", None) or ( @@ -1034,10 +1068,13 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: file_obj = {"file_data": content.uri} if filename: file_obj["filename"] = filename - return { - "type": "file", - "file": file_obj, - } + return attach_prompt_cache_breakpoint( + { + "type": "file", + "file": file_obj, + }, + content, + ) case _: # Default fallback for all other content types return content.to_dict(exclude_none=True) diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index 0541583c67e..c7b3eae1ef9 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -5,7 +5,7 @@ import sys from collections.abc import Awaitable, Callable, Mapping, Sequence from copy import copy -from typing import TYPE_CHECKING, Any, Literal, Union +from typing import TYPE_CHECKING, Any, Literal, Union, cast from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import APP_INFO, prepend_agent_framework_to_user_agent @@ -24,6 +24,7 @@ from typing_extensions import TypedDict # pragma: no cover if TYPE_CHECKING: + from agent_framework import Content from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -49,6 +50,39 @@ AzureTokenProvider = Callable[[], str | Awaitable[str]] +class PromptCacheOptions(TypedDict, total=False): + """Request-wide prompt cache policy, supported by GPT-5.6 and later models. + + Keys: + mode: 'implicit' (default) places an automatic cache breakpoint on the latest + message in addition to any explicit ones; 'explicit' uses only the + breakpoints set on content parts via ``prompt_cache_breakpoint``. + ttl: How long a cache breakpoint stays active; currently only '30m'. + + See: https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints + """ + + mode: Literal["implicit", "explicit"] + ttl: Literal["30m"] + + +PROMPT_CACHE_BREAKPOINT_KEY = "prompt_cache_breakpoint" + + +def attach_prompt_cache_breakpoint(part: dict[str, Any], content: "Content") -> dict[str, Any]: + """Copy a prompt cache breakpoint from content metadata onto an outgoing part. + + GPT-5.6 and later models accept an explicit cache breakpoint on supported content + blocks; users opt in per part via + ``Content.additional_properties["prompt_cache_breakpoint"]``. + """ + props = content.additional_properties + breakpoint_value = props.get(PROMPT_CACHE_BREAKPOINT_KEY) if props else None + if isinstance(breakpoint_value, Mapping): + part[PROMPT_CACHE_BREAKPOINT_KEY] = dict(cast("Mapping[str, Any]", breakpoint_value)) + return part + + class OpenAISettings(TypedDict, total=False): """OpenAI environment settings. diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 0497d3ddf0f..b198e9242be 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -7067,3 +7067,71 @@ def test_prepare_messages_strips_mcp_items_under_storage() -> None: # endregion + + +# region Prompt cache breakpoints and options + + +def _breakpoint_text_content() -> Content: + return Content.from_text( + "This is a stable prefix that should be cached.", + additional_properties={"prompt_cache_breakpoint": {"mode": "explicit"}}, + ) + + +def test_prepare_messages_for_openai_text_prompt_cache_breakpoint() -> None: + """A text part carries an explicit prompt cache breakpoint onto the request.""" + client = OpenAIChatClient(api_key="test-api-key", model="test-model") + items = client._prepare_messages_for_openai( + [Message(role="user", contents=[_breakpoint_text_content()])], + request_uses_service_side_storage=False, + ) + part = items[0]["content"][0] + assert part["type"] == "input_text" + assert part["prompt_cache_breakpoint"] == {"mode": "explicit"} + + +def test_prepare_content_for_openai_image_prompt_cache_breakpoint() -> None: + """An image part carries an explicit prompt cache breakpoint onto the request.""" + client = OpenAIChatClient(api_key="test-api-key", model="test-model") + image = Content.from_uri( + uri="https://example.com/x.png", + media_type="image/png", + additional_properties={"prompt_cache_breakpoint": {"mode": "explicit"}}, + ) + part = client._prepare_content_for_openai("user", image) + assert part["type"] == "input_image" + assert part["prompt_cache_breakpoint"] == {"mode": "explicit"} + + +def test_prepare_content_for_openai_file_prompt_cache_breakpoint() -> None: + """A file part carries an explicit prompt cache breakpoint onto the request.""" + client = OpenAIChatClient(api_key="test-api-key", model="test-model") + file_content = Content.from_uri( + uri="data:application/pdf;base64,AAAA", + media_type="application/pdf", + additional_properties={"prompt_cache_breakpoint": {"mode": "explicit"}}, + ) + part = client._prepare_content_for_openai("user", file_content) + assert part["type"] == "input_file" + assert part["prompt_cache_breakpoint"] == {"mode": "explicit"} + + +def test_prepare_content_for_openai_no_prompt_cache_breakpoint_by_default() -> None: + """Parts without the property keep their existing shape.""" + client = OpenAIChatClient(api_key="test-api-key", model="test-model") + part = client._prepare_content_for_openai("user", Content.from_text("hello")) + assert part == {"type": "input_text", "text": "hello"} + + +async def test_prepare_options_prompt_cache_options_passthrough() -> None: + """Request-level prompt_cache_options reaches the Responses API run options.""" + client = OpenAIChatClient(api_key="test-api-key", model="test-model") + run_options = await client._prepare_options( + [Message(role="user", contents=[Content.from_text("hi")])], + {"model": "test-model", "prompt_cache_options": {"mode": "explicit", "ttl": "30m"}}, + ) + assert run_options["prompt_cache_options"] == {"mode": "explicit", "ttl": "30m"} + + +# endregion diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 81d9963688b..7445017c19a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2088,3 +2088,74 @@ def test_streaming_chunk_with_null_delta_no_tool_calls_parsed( # endregion + + +# region Prompt cache breakpoints and options + + +def test_prepare_message_text_prompt_cache_breakpoint_keeps_parts() -> None: + """A text part with a breakpoint stays in list form and carries the key.""" + client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") + content = Content.from_text( + "stable prefix", + additional_properties={"prompt_cache_breakpoint": {"mode": "explicit"}}, + ) + msgs = client._prepare_message_for_openai(Message(role="user", contents=[content])) + assert isinstance(msgs[0]["content"], list) + assert msgs[0]["content"][0] == { + "type": "text", + "text": "stable prefix", + "prompt_cache_breakpoint": {"mode": "explicit"}, + } + + +def test_prepare_message_text_without_breakpoint_flattens_to_string() -> None: + """Text-only content without a breakpoint keeps the plain-string form.""" + client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") + msgs = client._prepare_message_for_openai(Message(role="user", contents=[Content.from_text("hello")])) + assert msgs[0]["content"] == "hello" + + +def test_prepare_message_system_prompt_cache_breakpoint_keeps_parts() -> None: + """A system message with a breakpoint keeps typed content parts.""" + client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") + content = Content.from_text( + "system prefix", + additional_properties={"prompt_cache_breakpoint": {"mode": "explicit"}}, + ) + msgs = client._prepare_message_for_openai(Message(role="system", contents=[content])) + assert isinstance(msgs[0]["content"], list) + assert msgs[0]["content"][0]["prompt_cache_breakpoint"] == {"mode": "explicit"} + + +def test_prepare_message_system_without_breakpoint_keeps_string_form() -> None: + """System messages without a breakpoint keep the joined-string form.""" + client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") + msgs = client._prepare_message_for_openai(Message(role="system", contents=[Content.from_text("plain system")])) + assert msgs[0]["content"] == "plain system" + + +def test_prepare_content_for_openai_image_prompt_cache_breakpoint() -> None: + """An image part carries an explicit prompt cache breakpoint onto the request.""" + client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") + image = Content.from_uri( + uri="https://example.com/x.png", + media_type="image/png", + additional_properties={"prompt_cache_breakpoint": {"mode": "explicit"}}, + ) + part = client._prepare_content_for_openai(image) + assert part["type"] == "image_url" + assert part["prompt_cache_breakpoint"] == {"mode": "explicit"} + + +def test_prepare_options_prompt_cache_options_passthrough() -> None: + """Request-level prompt_cache_options reaches the Chat Completions run options.""" + client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") + run_options = client._prepare_options( + [Message(role="user", contents=[Content.from_text("hi")])], + {"model": "test-model", "prompt_cache_options": {"mode": "explicit", "ttl": "30m"}}, + ) + assert run_options["prompt_cache_options"] == {"mode": "explicit", "ttl": "30m"} + + +# endregion From 4f9c14fa578a99a30e8a3080622c0ae9c74a519c Mon Sep 17 00:00:00 2001 From: Mordris <118046112+Mordris@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:29:04 +0300 Subject: [PATCH 2/6] Clarify system-message content-shape comment --- .../agent_framework_openai/_chat_completion_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 9905cb8e70c..265a99772e7 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -882,8 +882,10 @@ def _prepare_messages_for_openai( def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: """Prepare a chat message for OpenAI.""" - # System/developer messages must use plain string content because some - # OpenAI-compatible endpoints reject list content for non-user roles. + # System/developer messages default to plain string content because some + # OpenAI-compatible endpoints reject list content for non-user roles. The + # exception is a prompt cache breakpoint on a text part: it can only live on + # a typed part, so opting in switches that message to list content. if message.role in ("system", "developer"): text_contents = [content for content in message.contents if content.type == "text" and content.text] if text_contents: From 55ff4b6572896c09ab99a77d44091ccaebd1c6b1 Mon Sep 17 00:00:00 2001 From: Mordris <118046112+Mordris@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:17:16 +0300 Subject: [PATCH 3/6] Address review: SDK prompt cache types, private helper, add sample Replace the custom PromptCacheOptions TypedDict with the openai SDK's own types for each API, which raises the openai floor to 2.45.0 where those types were introduced. Make the breakpoint helper private to the two chat clients. Add a prompt caching sample with a README entry, and unquote the helper's Content annotation so the pyupgrade hook passes. --- .../agent_framework_openai/_chat_client.py | 10 +-- .../_chat_completion_client.py | 13 ++- .../openai/agent_framework_openai/_shared.py | 20 +---- python/packages/openai/pyproject.toml | 2 +- .../02-agents/providers/openai/README.md | 1 + .../providers/openai/client_prompt_caching.py | 86 +++++++++++++++++++ python/uv.lock | 2 +- 7 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 python/samples/02-agents/providers/openai/client_prompt_caching.py diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 4cdc89a896c..7e1f03bd21a 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -72,6 +72,7 @@ ParsedResponse, ) from openai.types.responses.response import Response as OpenAIResponse +from openai.types.responses.response_create_params import PromptCacheOptions from openai.types.responses.response_stream_event import ( ResponseStreamEvent as OpenAIResponseStreamEvent, ) @@ -88,8 +89,7 @@ from ._exceptions import OpenAIContentFilterException from ._shared import ( AzureTokenProvider, - PromptCacheOptions, - attach_prompt_cache_breakpoint, + _attach_prompt_cache_breakpoint, # pyright: ignore[reportPrivateUsage] load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -1663,7 +1663,7 @@ def _prepare_content_for_openai( "text": content.text, "annotations": _annotations_to_output_text(getattr(content, "annotations", None)), } - return attach_prompt_cache_breakpoint( + return _attach_prompt_cache_breakpoint( { "type": "input_text", "text": content.text, @@ -1697,7 +1697,7 @@ def _prepare_content_for_openai( file_id = content.additional_properties.get("file_id") if content.additional_properties else None if file_id is not None: result["file_id"] = file_id - return attach_prompt_cache_breakpoint(result, content) + return _attach_prompt_cache_breakpoint(result, content) if content.has_top_level_media_type("audio"): if content.media_type and "wav" in content.media_type: format = "wav" @@ -1725,7 +1725,7 @@ def _prepare_content_for_openai( } if filename: file_obj["filename"] = filename - return attach_prompt_cache_breakpoint(file_obj, content) + return _attach_prompt_cache_breakpoint(file_obj, content) return {} case "function_call": if not content.call_id: diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 265a99772e7..a61be094c33 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -54,15 +54,14 @@ from openai.types.chat.chat_completion_message_custom_tool_call import ( ChatCompletionMessageCustomToolCall, ) -from openai.types.chat.completion_create_params import WebSearchOptions +from openai.types.chat.completion_create_params import PromptCacheOptions, WebSearchOptions from pydantic import BaseModel from ._exceptions import OpenAIContentFilterException from ._shared import ( PROMPT_CACHE_BREAKPOINT_KEY, AzureTokenProvider, - PromptCacheOptions, - attach_prompt_cache_breakpoint, + _attach_prompt_cache_breakpoint, # pyright: ignore[reportPrivateUsage] load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -1007,7 +1006,7 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: """Prepare content for OpenAI.""" match content.type: case "text": - return attach_prompt_cache_breakpoint( + return _attach_prompt_cache_breakpoint( {"type": "text", "text": content.text}, content, ) @@ -1028,7 +1027,7 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: detail = content.additional_properties.get("detail") if isinstance(detail, str): image_url_obj["detail"] = detail - return attach_prompt_cache_breakpoint( + return _attach_prompt_cache_breakpoint( { "type": "image_url", "image_url": image_url_obj, @@ -1050,7 +1049,7 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: # Extract just the base64 part after "data:audio/format;base64," audio_data = audio_data.split(",", 1)[-1] # type: ignore[union-attr] - return attach_prompt_cache_breakpoint( + return _attach_prompt_cache_breakpoint( { "type": "input_audio", "input_audio": { @@ -1070,7 +1069,7 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: file_obj = {"file_data": content.uri} if filename: file_obj["filename"] = filename - return attach_prompt_cache_breakpoint( + return _attach_prompt_cache_breakpoint( { "type": "file", "file": file_obj, diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index c7b3eae1ef9..80f2ec2b598 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -50,26 +50,12 @@ AzureTokenProvider = Callable[[], str | Awaitable[str]] -class PromptCacheOptions(TypedDict, total=False): - """Request-wide prompt cache policy, supported by GPT-5.6 and later models. - - Keys: - mode: 'implicit' (default) places an automatic cache breakpoint on the latest - message in addition to any explicit ones; 'explicit' uses only the - breakpoints set on content parts via ``prompt_cache_breakpoint``. - ttl: How long a cache breakpoint stays active; currently only '30m'. - - See: https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints - """ - - mode: Literal["implicit", "explicit"] - ttl: Literal["30m"] - - PROMPT_CACHE_BREAKPOINT_KEY = "prompt_cache_breakpoint" -def attach_prompt_cache_breakpoint(part: dict[str, Any], content: "Content") -> dict[str, Any]: +def _attach_prompt_cache_breakpoint( # pyright: ignore[reportUnusedFunction] + part: dict[str, Any], content: Content +) -> dict[str, Any]: """Copy a prompt cache breakpoint from content metadata onto an outgoing part. GPT-5.6 and later models accept an explicit cache breakpoint on supported content diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 63f4b925d50..54f4390e4b2 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.11.0,<2", - "openai>=2.25.0,<3", + "openai>=2.45.0,<3", ] [tool.uv] diff --git a/python/samples/02-agents/providers/openai/README.md b/python/samples/02-agents/providers/openai/README.md index a8ec0f6d1e4..7247833d003 100644 --- a/python/samples/02-agents/providers/openai/README.md +++ b/python/samples/02-agents/providers/openai/README.md @@ -22,6 +22,7 @@ This folder contains OpenAI provider samples for the generic clients in | [`client_basic.py`](client_basic.py) | Basic non-streaming and streaming responses sample with an explicit `gpt-5.4-nano` model and API key. | | [`client_image_analysis.py`](client_image_analysis.py) | Analyze images with the responses client. | | [`client_image_generation.py`](client_image_generation.py) | Generate images from text prompts. | +| [`client_prompt_caching.py`](client_prompt_caching.py) | Explicit prompt cache breakpoints and `prompt_cache_options` on GPT-5.6 models. | | [`client_reasoning.py`](client_reasoning.py) | Reasoning-focused sample for models such as `gpt-5`. | | [`client_streaming_image_generation.py`](client_streaming_image_generation.py) | Streaming image generation sample. | | [`client_verbosity.py`](client_verbosity.py) | GPT-5 `verbosity` option (`low`/`medium`/`high`) with default and per-call overrides. | diff --git a/python/samples/02-agents/providers/openai/client_prompt_caching.py b/python/samples/02-agents/providers/openai/client_prompt_caching.py new file mode 100644 index 00000000000..38afdaf93e6 --- /dev/null +++ b/python/samples/02-agents/providers/openai/client_prompt_caching.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Content, Message +from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions +from dotenv import load_dotenv + +load_dotenv() + +""" +OpenAI Chat Client Prompt Caching Example + +Demonstrates explicit prompt cache breakpoints on GPT-5.6 and later models. Cache +writes are billed on these models, so marking exactly where a reusable prefix ends +lets you control what gets cached. + +Two knobs work together: + +- ``prompt_cache_options`` on ``OpenAIChatOptions`` sets the request-wide policy. + ``{"mode": "explicit"}`` disables the automatic breakpoint on the latest message, + so only the breakpoints you place are used for cache reads and writes. +- ``Content.additional_properties["prompt_cache_breakpoint"]`` marks the end of the + reusable prefix on a specific content part. + +The content before a breakpoint must be at least 1024 tokens long to be cached. +Running the same prefix twice shows the cache hit through +``usage_details["cache_read_input_token_count"]`` on later responses. + +Environment variables: + OPENAI_API_KEY — OpenAI API key + +See: https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints +""" + +# A stable block of context that is reused across requests, for example a product +# catalog, a policy document, or long system guidance. Repeated here to clear the +# 1024-token minimum a cache breakpoint requires. +STABLE_CONTEXT = ( + "You are a support assistant for the Contoso appliance store. " + "Always answer briefly, quote the relevant catalog section, and never invent " + "model numbers. If a question is out of scope, say so and point the customer " + "to support@contoso.example. " +) * 40 + + +def build_messages(question: str) -> list[Message]: + """Build a request with a cache breakpoint at the end of the stable prefix.""" + return [ + Message( + role="user", + contents=[ + Content.from_text( + STABLE_CONTEXT, + additional_properties={"prompt_cache_breakpoint": {"mode": "explicit"}}, + ) + ], + ), + Message(role="user", contents=[Content.from_text(question)]), + ] + + +async def main() -> None: + print("\033[92m=== OpenAI Chat Client Prompt Caching Example ===\033[0m\n") + + client = OpenAIChatClient[OpenAIChatOptions](model="gpt-5.6-luna") + options: OpenAIChatOptions = {"prompt_cache_options": {"mode": "explicit"}} + + questions = ["Do you sell refrigerators?", "What is the return policy contact?"] + for turn, question in enumerate(questions, start=1): + response = await client.get_response(build_messages(question), options=options) + usage = response.usage_details or {} + cached = usage.get("cache_read_input_token_count", 0) + print(f"Turn {turn}: {question}") + print(f" Answer: {response.text}") + print(f" Cached input tokens: {cached}\n") + if turn < len(questions): + # A freshly written cache entry becomes readable shortly after the request + # completes; the brief pause keeps the next turn from racing this one. + await asyncio.sleep(2) + + print("The first turn writes the prefix to the cache; later turns read it back.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index d8ff1bd1b0a..1974f3239be 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -946,7 +946,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "openai", specifier = ">=2.25.0,<3" }, + { name = "openai", specifier = ">=2.45.0,<3" }, ] [[package]] From bccc023c9705ec815c5a53567e20a9ccc3c0d842 Mon Sep 17 00:00:00 2001 From: Mordris <118046112+Mordris@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:43:30 +0300 Subject: [PATCH 4/6] Guard the prompt cache options import for older openai versions The SDK's PromptCacheOptions types only exist in openai 2.45.0 and later, so each client falls back to a local mirror when the import fails and the dependency floor stays at 2.25.0. A TYPE_CHECKING-only import is not enough because the options classes are introspected with get_type_hints() at runtime. Verified against openai 2.25.0: the package imports, the fallback resolves, and part-level breakpoints still work; sending the option itself requires 2.45.0, which the field docstrings now note. --- .../openai/agent_framework_openai/_chat_client.py | 13 ++++++++++++- .../_chat_completion_client.py | 14 +++++++++++++- python/packages/openai/pyproject.toml | 2 +- python/uv.lock | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 7e1f03bd21a..3da555e1f55 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -72,7 +72,6 @@ ParsedResponse, ) from openai.types.responses.response import Response as OpenAIResponse -from openai.types.responses.response_create_params import PromptCacheOptions from openai.types.responses.response_stream_event import ( ResponseStreamEvent as OpenAIResponseStreamEvent, ) @@ -107,6 +106,17 @@ else: from typing_extensions import TypedDict # pragma: no cover +try: + from openai.types.responses.response_create_params import PromptCacheOptions +except ImportError: # pragma: no cover + + class PromptCacheOptions(TypedDict, total=False): + """Fallback for openai versions that predate prompt cache options.""" + + mode: Literal["implicit", "explicit"] + ttl: Literal["30m"] + + if TYPE_CHECKING: from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -238,6 +248,7 @@ class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], """Request-wide prompt cache policy for GPT-5.6 and later models. Set mode to 'explicit' to use only the breakpoints set on content parts via ``Content.additional_properties["prompt_cache_breakpoint"]``. + Sending this option requires openai 2.45.0 or later. See: https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints""" reasoning: ReasoningOptions diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index a61be094c33..cfd0405d6cb 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -54,7 +54,7 @@ from openai.types.chat.chat_completion_message_custom_tool_call import ( ChatCompletionMessageCustomToolCall, ) -from openai.types.chat.completion_create_params import PromptCacheOptions, WebSearchOptions +from openai.types.chat.completion_create_params import WebSearchOptions from pydantic import BaseModel from ._exceptions import OpenAIContentFilterException @@ -79,6 +79,17 @@ else: from typing_extensions import TypedDict # pragma: no cover +try: + from openai.types.chat.completion_create_params import PromptCacheOptions +except ImportError: # pragma: no cover + + class PromptCacheOptions(TypedDict, total=False): + """Fallback for openai versions that predate prompt cache options.""" + + mode: Literal["implicit", "explicit"] + ttl: Literal["30m"] + + if TYPE_CHECKING: from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -163,6 +174,7 @@ class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseM """Request-wide prompt cache policy for GPT-5.6 and later models. Set mode to 'explicit' to use only the breakpoints set on content parts via ``Content.additional_properties["prompt_cache_breakpoint"]``. + Sending this option requires openai 2.45.0 or later. See: https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints""" diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 54f4390e4b2..63f4b925d50 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.11.0,<2", - "openai>=2.45.0,<3", + "openai>=2.25.0,<3", ] [tool.uv] diff --git a/python/uv.lock b/python/uv.lock index 1974f3239be..d8ff1bd1b0a 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -946,7 +946,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "openai", specifier = ">=2.45.0,<3" }, + { name = "openai", specifier = ">=2.25.0,<3" }, ] [[package]] From c9e05b4a927bb2667a72bda01518c1c6cad30c37 Mon Sep 17 00:00:00 2001 From: Mordris <118046112+Mordris@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:51:20 +0300 Subject: [PATCH 5/6] Make the old-openai fallback for PromptCacheOptions deliberately empty Assigning None instead, as suggested in review, trips pyright's reportInvalidTypeForm on the field annotation (the symbol becomes type | None after the try/except). An empty TypedDict gives the same effect for users on older openai versions: any content they put in prompt_cache_options is flagged by their type checker, since the option cannot be sent on those versions anyway, while get_type_hints() on the options classes keeps working at runtime. --- .../packages/openai/agent_framework_openai/_chat_client.py | 7 ++++--- .../agent_framework_openai/_chat_completion_client.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 3da555e1f55..6ab97573235 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -111,10 +111,11 @@ except ImportError: # pragma: no cover class PromptCacheOptions(TypedDict, total=False): - """Fallback for openai versions that predate prompt cache options.""" + """Fallback for openai versions that predate prompt cache options. - mode: Literal["implicit", "explicit"] - ttl: Literal["30m"] + Deliberately empty: the option cannot be sent on these versions, so any + content in the field is flagged by type checkers. + """ if TYPE_CHECKING: diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index cfd0405d6cb..c3d6cd23d84 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -84,10 +84,11 @@ except ImportError: # pragma: no cover class PromptCacheOptions(TypedDict, total=False): - """Fallback for openai versions that predate prompt cache options.""" + """Fallback for openai versions that predate prompt cache options. - mode: Literal["implicit", "explicit"] - ttl: Literal["30m"] + Deliberately empty: the option cannot be sent on these versions, so any + content in the field is flagged by type checkers. + """ if TYPE_CHECKING: From 69f8e1019816fe040a25d7769e2b55af62318e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunus=20Emre=20G=C3=BCltepe?= Date: Tue, 21 Jul 2026 17:59:37 +0300 Subject: [PATCH 6/6] Guard prompt_cache_options at runtime instead of via an empty fallback type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-TypedDict fallback flagged valid `prompt_cache_options` usage under pyright on every openai version — including this PR's own `client_prompt_caching.py` sample (`poe check -S`) — because pyright resolves the try/except symbol to the fallback shape regardless of the installed openai, while mypy/ty resolve the failed import to `Any` and never warn. So a type-only "warn on old openai" signal is not achievable cleanly across type checkers. Restore the faithful fallback (mirrors the SDK's `mode`/`ttl` shape) so the option type-checks identically on every supported openai version, and add a runtime guard: setting `prompt_cache_options` on openai < 2.45 now raises a clear ChatClientInvalidRequestException instead of forwarding an unusable option to the SDK. This keeps the option non-silent for all users regardless of type checker, without forcing an openai upgrade. Adds tests covering the guard for both clients. --- .../agent_framework_openai/_chat_client.py | 16 ++++++++++++++-- .../_chat_completion_client.py | 16 ++++++++++++++-- .../tests/openai/test_openai_chat_client.py | 13 +++++++++++++ .../openai/test_openai_chat_completion_client.py | 15 +++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 6ab97573235..f18af555099 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -108,15 +108,22 @@ try: from openai.types.responses.response_create_params import PromptCacheOptions + + _prompt_cache_options_supported = True except ImportError: # pragma: no cover + _prompt_cache_options_supported = False class PromptCacheOptions(TypedDict, total=False): """Fallback for openai versions that predate prompt cache options. - Deliberately empty: the option cannot be sent on these versions, so any - content in the field is flagged by type checkers. + Mirrors the SDK's shape so ``prompt_cache_options`` type-checks the same on + every supported openai version; a runtime guard rejects the option when the + installed openai is too old to send it. """ + mode: Literal["implicit", "explicit"] + ttl: Literal["30m"] + if TYPE_CHECKING: from azure.core.credentials import TokenCredential @@ -1400,6 +1407,11 @@ async def _prepare_options( } run_options: dict[str, Any] = {k: v for k, v in options.items() if k not in exclude_keys and v is not None} + if run_options.get("prompt_cache_options") is not None and not _prompt_cache_options_supported: + raise ChatClientInvalidRequestException( + "prompt_cache_options requires openai>=2.45.0; upgrade the openai package to use it." + ) + # messages # Handle instructions by prepending to messages as system message # Only prepend instructions for the first turn (when no conversation/response ID exists) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index c3d6cd23d84..1f5e73d0567 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -81,15 +81,22 @@ try: from openai.types.chat.completion_create_params import PromptCacheOptions + + _prompt_cache_options_supported = True except ImportError: # pragma: no cover + _prompt_cache_options_supported = False class PromptCacheOptions(TypedDict, total=False): """Fallback for openai versions that predate prompt cache options. - Deliberately empty: the option cannot be sent on these versions, so any - content in the field is flagged by type checkers. + Mirrors the SDK's shape so ``prompt_cache_options`` type-checks the same on + every supported openai version; a runtime guard rejects the option when the + installed openai is too old to send it. """ + mode: Literal["implicit", "explicit"] + ttl: Literal["30m"] + if TYPE_CHECKING: from azure.core.credentials import TokenCredential @@ -668,6 +675,11 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An k: v for k, v in options.items() if v is not None and k not in {"instructions", "tools", "conversation_id"} } + if run_options.get("prompt_cache_options") is not None and not _prompt_cache_options_supported: + raise ChatClientInvalidRequestException( + "prompt_cache_options requires openai>=2.45.0; upgrade the openai package to use it." + ) + # messages if messages and "messages" not in run_options: run_options["messages"] = self._prepare_messages_for_openai(messages) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index b198e9242be..01239f9a83a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -7134,4 +7134,17 @@ async def test_prepare_options_prompt_cache_options_passthrough() -> None: assert run_options["prompt_cache_options"] == {"mode": "explicit", "ttl": "30m"} +async def test_prepare_options_prompt_cache_options_guarded_on_old_openai(monkeypatch: pytest.MonkeyPatch) -> None: + """Setting prompt_cache_options on an openai too old to send it raises a clear error.""" + import agent_framework_openai._chat_client as chat_client_module + + monkeypatch.setattr(chat_client_module, "_prompt_cache_options_supported", False) + client = OpenAIChatClient(api_key="test-api-key", model="test-model") + with pytest.raises(ChatClientInvalidRequestException, match="openai>=2.45.0"): + await client._prepare_options( + [Message(role="user", contents=[Content.from_text("hi")])], + {"model": "test-model", "prompt_cache_options": {"mode": "explicit"}}, + ) + + # endregion diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 7445017c19a..8c965c6584b 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2158,4 +2158,19 @@ def test_prepare_options_prompt_cache_options_passthrough() -> None: assert run_options["prompt_cache_options"] == {"mode": "explicit", "ttl": "30m"} +def test_prepare_options_prompt_cache_options_guarded_on_old_openai(monkeypatch: pytest.MonkeyPatch) -> None: + """Setting prompt_cache_options on an openai too old to send it raises a clear error.""" + from agent_framework.exceptions import ChatClientInvalidRequestException + + import agent_framework_openai._chat_completion_client as chat_completion_module + + monkeypatch.setattr(chat_completion_module, "_prompt_cache_options_supported", False) + client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") + with pytest.raises(ChatClientInvalidRequestException, match="openai>=2.45.0"): + client._prepare_options( + [Message(role="user", contents=[Content.from_text("hi")])], + {"model": "test-model", "prompt_cache_options": {"mode": "explicit"}}, + ) + + # endregion