fix(openai): carry cache_control to prompt_cache_breakpoint on /v1/responses to gpt-5.6 (#46105)

* fix(openai): carry cache_control to prompt_cache_breakpoint on /v1/responses to gpt-5.6

Native /v1/responses used to strip every Anthropic cache_control mark, so a
gpt-5.6 deployment in explicit mode never wrote a cache. Marks on system,
developer and user parts and on function_call_output now become
prompt_cache_breakpoint on the same block when the deployment reads
breakpoints, and are stripped exactly as before everywhere else

* fix(openai): read str providers and base_url in the prompt cache breakpoint gate

Responses configs generated from a provider JSON file, and openai_like
itself, name their provider as a plain str, so .value crashed every native
Responses request through them. The gate also only read api_base, while
responses() keeps a caller's base_url as an extra field on the params, so an
injected mark on a custom host was rewritten to a breakpoint

* test(openai): cover the pass-through branches of the prompt cache breakpoint swap

A part type OpenAI does not cache keeps no breakpoint and an item mark skips
it, and an item or content of a shape OpenAI will reject reaches the strip
untouched instead of failing inside the proxy

* fix(prompt_caching): keep a client-written prompt_cache_breakpoint when a cache_control mark lands on the same block
This commit is contained in:
ryan-crabbe-berri 2026-10-13 15:23:07 -07:00 • committed by GitHub
parent 92faffa78d
commit 7bd108139f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 678 additions and 9 deletions

View file

@ -1788,7 +1788,8 @@ _MarkedT: Final = TypeVar("_MarkedT", bound=Mapping[str, object])
def with_prompt_cache_breakpoint(target: _MarkedT, marker: object) -> _MarkedT:
if marker is None:
"""A breakpoint the client already wrote on the block wins, TTL included."""
if marker is None or target.get("prompt_cache_breakpoint") is not None:
return target
marked: Final = {**target, "prompt_cache_breakpoint": marker}
return cast(_MarkedT, marked) # cast-ok: same block shape as the input plus the marker key

View file

@ -10,14 +10,17 @@ from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
safe_convert_created_field,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
cache_control_as_prompt_cache_breakpoint,
drop_non_python_regex_patterns,
flatten_combinators_and_drop_non_python_regex_patterns,
with_prompt_cache_breakpoint,
)
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
@ -51,6 +54,75 @@ _MODEL_FAMILIES_REJECTING_TOP_LEVEL_SCHEMA_COMBINATORS: Final = ("gpt-4", "gpt-3
_PROVIDERS_WITH_OPENAI_SCHEMA_VALIDATOR: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI})
_PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI})
_PROVIDERS_REPLAYING_ONLY_THEIR_OWN_REASONING: Final = _PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS
_ROLES_READING_PROMPT_CACHE_BREAKPOINTS: Final = frozenset({"system", "developer", "user"})
_PART_TYPES_READING_PROMPT_CACHE_BREAKPOINTS: Final = frozenset({"input_text", "input_image", "input_file"})
_INPUT_ITEM: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
_CONTENT_PARTS: Final[TypeAdapter[Sequence[Mapping[str, object]]]] = TypeAdapter(Sequence[Mapping[str, object]])
class _DeploymentHost(BaseModel):
"""`responses()` builds the params from raw kwargs, where callers may spell the host `base_url`."""
model_config = ConfigDict(frozen=True, extra="ignore", from_attributes=True)
api_base: object = None
base_url: object = None
def _provider_name(provider: LlmProviders | str) -> str:
"""Configs generated from a provider JSON file return the slug as a plain str."""
return provider.value if isinstance(provider, LlmProviders) else provider
def _item_with_prompt_cache_breakpoints(item: object) -> object:
"""Marks on assistant output and on tools are left for the strip that follows: OpenAI writes no cache there."""
try:
fields: Final = _INPUT_ITEM.validate_python(item)
except ValidationError:
return item
if fields.get("type") == "function_call_output":
return _with_prompt_cache_breakpoints_under(fields, "output")
if fields.get("type", "message") == "message" and fields.get("role") in _ROLES_READING_PROMPT_CACHE_BREAKPOINTS:
return _with_prompt_cache_breakpoints_under(fields, "content")
return item
def _with_prompt_cache_breakpoints_under(item: Mapping[str, object], key: str) -> Mapping[str, object]:
value: Final = item.get(key)
item_mark: Final = item.get("cache_control")
unmarked_item: Final = {name: field for name, field in item.items() if name != "cache_control"}
if isinstance(value, str):
if item_mark is None:
return item
text_part: Final[Mapping[str, object]] = {"type": "input_text", "text": value}
return {**unmarked_item, key: [with_prompt_cache_breakpoint(text_part, PromptCacheBreakpoint(mode="explicit"))]}
try:
parts: Final = _CONTENT_PARTS.validate_python(value)
except ValidationError:
return item
swapped_parts: Final = tuple(_part_with_prompt_cache_breakpoint(part) for part in parts)
return {**unmarked_item, key: list(_with_breakpoint_on_last_reading_part(swapped_parts, item_mark))}
def _part_with_prompt_cache_breakpoint(part: Mapping[str, object]) -> Mapping[str, object]:
if part.get("type") not in _PART_TYPES_READING_PROMPT_CACHE_BREAKPOINTS:
return part
return cache_control_as_prompt_cache_breakpoint(part)
def _with_breakpoint_on_last_reading_part(
parts: Sequence[Mapping[str, object]], item_mark: object
) -> Sequence[Mapping[str, object]]:
if item_mark is None:
return parts
reading_indexes: Final = [
index for index, part in enumerate(parts) if part.get("type") in _PART_TYPES_READING_PROMPT_CACHE_BREAKPOINTS
]
if not reading_indexes:
return parts
last: Final = reading_indexes[-1]
marked_last: Final = with_prompt_cache_breakpoint(parts[last], PromptCacheBreakpoint(mode="explicit"))
return (*parts[:last], marked_last, *parts[last + 1 :])
class _ReasoningSupportEntry(LiteLLMBaseModel):
@ -293,19 +365,22 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> dict:
"""Strip Anthropic-only `cache_control` markers before sending to OpenAI.
OpenAI's Responses API rejects unknown fields on input content blocks
with HTTP 400 ("Unknown parameter: 'input[0].content[0].cache_control'").
Chat Completions strips these in
`remove_cache_control_flag_from_messages_and_tools`; mirror that here.
"""Anthropic-only `cache_control` marks become `prompt_cache_breakpoint` on the same block
where the deployment reads them (gpt-5.6 and newer on OpenAI's API, or a host opted in
through `prompt_cache_options`) and are stripped everywhere else, because OpenAI's
Responses API rejects unknown fields on input content blocks with HTTP 400
("Unknown parameter: 'input[0].content[0].cache_control'").
"""
request_params: Final = cast( # cast-ok: the base signature leaves the params bag untyped
"Mapping[str, object]", response_api_optional_request_params
)
replay_safe_input, sanitized_tools = self._prepared_input_and_tools(
model=model,
input=input,
tools=response_api_optional_request_params.get("tools"),
litellm_params=litellm_params,
prompt_cache_options=request_params.get("prompt_cache_options"),
)
if sanitized_tools is not None:
response_api_optional_request_params["tools"] = sanitized_tools
@ -321,10 +396,18 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
input: str | ResponseInputParam,
tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None,
litellm_params: GenericLiteLLMParams,
prompt_cache_options: object = None,
) -> tuple[str | ResponseInputParam, Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None]:
validated_input: Final = self._validate_input_param(self._drop_bridge_minted_reasoning_items(input))
marked_input: Final = (
self._input_with_prompt_cache_breakpoints(validated_input)
if self._reads_prompt_cache_breakpoints(
model=model, litellm_params=litellm_params, prompt_cache_options=prompt_cache_options
)
else validated_input
)
stripped_input, stripped_tools = self.remove_cache_control_flag_from_input_and_tools(
model=model, input=validated_input, tools=tools
model=model, input=marked_input, tools=tools
)
object_schema_tools: Final = self._tools_with_object_parameters(model=model, tools=stripped_tools)
sanitized_tools: Final = self._sanitized_tool_schemas_for_openai(
@ -332,6 +415,24 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
)
return self._drop_foreign_tool_call_item_ids(stripped_input), sanitized_tools
def _reads_prompt_cache_breakpoints(
self, model: str, litellm_params: GenericLiteLLMParams, prompt_cache_options: object
) -> bool:
host: Final = _DeploymentHost.model_validate(litellm_params)
return AnthropicCacheControlHook.targets_openai_prompt_cache_breakpoint(
model=model,
custom_llm_provider=_provider_name(self.custom_llm_provider),
api_base=host.api_base or host.base_url,
prompt_cache_options=prompt_cache_options,
)
@staticmethod
def _input_with_prompt_cache_breakpoints(input: str | ResponseInputParam) -> str | ResponseInputParam:
if not isinstance(input, list):
return input
marked_items: Final = [_item_with_prompt_cache_breakpoints(item) for item in input]
return cast("ResponseInputParam", marked_items) # cast-ok: items keep their shape, marks renamed in place
def _tools_with_object_parameters(
self, model: str, tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None
) -> Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None:
@ -951,11 +1052,15 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
compact_path: Final = parsed_url.path.rstrip("/") + "/compact"
url: Final = str(parsed_url.copy_with(path=compact_path))
request_params: Final = cast( # cast-ok: the base signature leaves the params bag untyped
"Mapping[str, object]", response_api_optional_request_params
)
replay_safe_input, sanitized_tools = self._prepared_input_and_tools(
model=model,
input=input,
tools=response_api_optional_request_params.get("tools"),
litellm_params=litellm_params,
prompt_cache_options=request_params.get("prompt_cache_options"),
)
if sanitized_tools is not None:
response_api_optional_request_params["tools"] = sanitized_tools

View file

@ -0,0 +1,229 @@
import uuid
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from typing import Final
import httpx
import openai
import pytest
from integration._support import prompt_cache_breakpoint as pcb
from integration._support import responses_vendor as rv
from integration._support.client import Gateway, gateway_from_environment
from integration._support.wire import Request, Wire, wire_server
from pydantic import JsonValue
pytestmark: Final = pytest.mark.timeout(120)
_EPHEMERAL: Final[Mapping[str, JsonValue]] = {"type": "ephemeral"}
_EPHEMERAL_1H: Final[Mapping[str, JsonValue]] = {"type": "ephemeral", "ttl": "1h"}
_OPTED_IN: Final[Mapping[str, JsonValue]] = {"prompt_cache_options": {"mode": "explicit"}}
_LOOKUP_TOOL: Final[Mapping[str, JsonValue]] = {
"type": "function",
"name": "lookup",
"parameters": {"type": "object", "properties": {}},
}
@dataclass(frozen=True, slots=True)
class _Native:
gateway: Gateway
wire: Wire
reading: str
stripping: str
spend: pcb.SpendLogs
@pytest.fixture(scope="module")
def native() -> Iterator[_Native]:
with (
wire_server(pcb.respond) as wire,
gateway_from_environment() as gateway,
gateway.scenario() as scenario,
pcb.spend_logs() as spend,
):
api_base: Final = f"{wire.url}/v1"
yield _Native(
gateway,
wire,
scenario.model(model=pcb.MODEL, api_base=api_base, **_OPTED_IN),
scenario.model(model=pcb.MODEL, api_base=api_base),
spend,
)
def _v1(gateway: Gateway) -> str:
return str(gateway.client.base_url).rstrip("/") + "/v1"
def _input_text(text: str) -> dict[str, JsonValue]:
return {"type": "input_text", "text": text}
def _marked(
block: Mapping[str, JsonValue], cache_control: Mapping[str, JsonValue] = _EPHEMERAL
) -> dict[str, JsonValue]:
return {**block, "cache_control": dict(cache_control)}
def _marked_history(marker: str) -> list[JsonValue]:
return [
{
"type": "message",
"role": "developer",
"content": [_input_text("Answer briefly"), _marked(_input_text("Stable rules"), _EPHEMERAL_1H)],
},
_marked({"role": "user", "content": pcb.prompt(marker)}),
]
def _responses(native: _Native, model: str, input: Sequence[JsonValue], *, stream: bool = False) -> httpx.Response:
return native.gateway.request(
"POST", "/v1/responses", {"model": model, "input": list(input), "stream": stream, **pcb.NO_CACHE}
)
def _answered(response: httpx.Response, marker: str) -> str:
assert response.status_code == 200, response.text
assert rv.answer(marker) in response.text, response.text
return response.headers["x-litellm-call-id"]
def _wire_body(request: Request, *, stream: bool = False) -> dict[str, JsonValue]:
body: Final = pcb.body_of(request)
assert body["model"] == "gpt-6.1-sol", body
assert (body.get("stream") is True) is stream, body
assert b"cache_control" not in request.body, request.body
return body
def _assert_marked_history_on_wire(body: Mapping[str, JsonValue], marker: str) -> None:
assert body["prompt_cache_options"] == {"mode": "explicit"}, body
assert body["input"] == [
{
"type": "message",
"role": "developer",
"content": [
_input_text("Answer briefly"),
pcb.marked(_input_text("Stable rules"), pcb.EXPLICIT),
],
},
{"role": "user", "content": [pcb.marked(_input_text(pcb.prompt(marker)), pcb.EXPLICIT)]},
], body
def test_openai_sdk_cache_control_reaches_the_wire_as_a_prompt_cache_breakpoint_on_the_same_block(
native: _Native,
) -> None:
marker: Final = uuid.uuid4().hex
with openai.OpenAI(api_key=native.gateway.key, base_url=_v1(native.gateway), max_retries=0) as client:
raw: Final = client.responses.with_raw_response.create(
model=native.reading, input=_marked_history(marker), extra_body=dict(pcb.NO_CACHE)
)
response: Final = raw.parse()
assert pcb.answers(response.id, marker), response
assert response.output_text == rv.answer(marker), response
_assert_marked_history_on_wire(_wire_body(pcb.posted(native.wire, marker)), marker)
native.spend.landed(native.reading, raw.headers["x-litellm-call-id"], marker)
def test_stream_carries_the_marks_the_same_way(native: _Native) -> None:
marker: Final = uuid.uuid4().hex
call_id: Final = _answered(_responses(native, native.reading, _marked_history(marker), stream=True), marker)
_assert_marked_history_on_wire(_wire_body(pcb.posted(native.wire, marker), stream=True), marker)
native.spend.landed(native.reading, call_id, marker)
@pytest.mark.parametrize(
("output", "expected"),
(
("found it", [pcb.marked(_input_text("found it"), pcb.EXPLICIT)]),
(
[_input_text("first"), _input_text("last")],
[_input_text("first"), pcb.marked(_input_text("last"), pcb.EXPLICIT)],
),
),
ids=("string-output", "list-output"),
)
def test_cache_control_on_a_function_call_output_marks_its_last_part(
native: _Native, output: JsonValue, expected: JsonValue
) -> None:
marker: Final = uuid.uuid4().hex
history: Final[list[JsonValue]] = [
{"role": "user", "content": pcb.prompt(marker)},
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
_marked({"type": "function_call_output", "call_id": "call_1", "output": output}),
]
call_id: Final = _answered(_responses(native, native.reading, history), marker)
request: Final = pcb.posted(native.wire, marker)
_wire_body(request)
assert pcb.function_output(pcb.input_items(request), "call_1") == expected, (
"OpenAI caches up to a breakpoint on a function_call_output part (prompt caching guide, read 2026-10-12)"
)
native.spend.landed(native.reading, call_id, marker)
def test_cache_control_on_an_assistant_turn_and_on_a_tool_is_dropped(native: _Native) -> None:
marker: Final = uuid.uuid4().hex
history: Final[list[JsonValue]] = [
{"role": "user", "content": "first question"},
{
"type": "message",
"role": "assistant",
"content": [_marked({"type": "output_text", "text": "earlier answer"})],
},
{"role": "user", "content": pcb.prompt(marker)},
]
response: Final = native.gateway.request(
"POST",
"/v1/responses",
{"model": native.reading, "input": history, "tools": [_marked(_LOOKUP_TOOL)], **pcb.NO_CACHE},
)
call_id: Final = _answered(response, marker)
request: Final = pcb.posted(native.wire, marker)
body: Final = _wire_body(request)
assert b"prompt_cache_breakpoint" not in request.body, request.body
assert pcb.content_of(pcb.input_items(request), "assistant") == [
{"type": "output_text", "text": "earlier answer"}
], "OpenAI accepts a breakpoint on output_text but writes no cache for it (live gpt-5.6, 2026-10-12)"
assert body["tools"] == [dict(_LOOKUP_TOOL)], body
native.spend.landed(native.reading, call_id, marker)
def test_cache_control_is_stripped_where_the_deployment_does_not_read_breakpoints(native: _Native) -> None:
marker: Final = uuid.uuid4().hex
call_id: Final = _answered(_responses(native, native.stripping, _marked_history(marker)), marker)
request: Final = pcb.posted(native.wire, marker)
body: Final = _wire_body(request)
assert "prompt_cache_options" not in body, body
assert b"prompt_cache_breakpoint" not in request.body, request.body
assert body["input"] == [
{
"type": "message",
"role": "developer",
"content": [_input_text("Answer briefly"), _input_text("Stable rules")],
},
{"role": "user", "content": pcb.prompt(marker)},
], body
native.spend.landed(native.stripping, call_id, marker)
def test_a_breakpoint_the_client_already_wrote_is_left_alone_beside_a_converted_mark(native: _Native) -> None:
marker: Final = uuid.uuid4().hex
history: Final[list[JsonValue]] = [
{
"role": "user",
"content": [
pcb.marked(_input_text(pcb.prompt(marker)), pcb.EXPLICIT_30M),
_marked(_input_text("and nothing else")),
],
}
]
call_id: Final = _answered(_responses(native, native.reading, history), marker)
request: Final = pcb.posted(native.wire, marker)
_wire_body(request)
(item,) = pcb.input_items(request)
assert item["content"] == [
pcb.marked(_input_text(pcb.prompt(marker)), pcb.EXPLICIT_30M),
pcb.marked(_input_text("and nothing else"), pcb.EXPLICIT),
], item
native.spend.landed(native.reading, call_id, marker)

View file

@ -30,6 +30,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
strip_encrypted_reasoning_from_messages,
system_messages_first,
update_messages_with_model_file_ids,
with_prompt_cache_breakpoint,
)
_ARTIFACT_FIELD_PATTERN: Final = r'^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\./[\]]{1,200}$'
@ -2280,3 +2281,28 @@ def test_a_block_without_cache_control_is_returned_untouched() -> None:
unmarked: Final = {"type": "text", "text": "rules"}
assert cache_control_as_prompt_cache_breakpoint(unmarked) is unmarked
def test_a_breakpoint_the_client_wrote_survives_a_message_level_mark_ttl_included() -> None:
block: Final = {
"type": "input_text",
"text": "rules",
"prompt_cache_breakpoint": {"mode": "explicit", "ttl": "30m"},
}
assert with_prompt_cache_breakpoint(block, {"mode": "explicit"}) is block
def test_a_breakpoint_the_client_wrote_survives_a_per_part_cache_control() -> None:
both: Final = {
"type": "input_text",
"text": "rules",
"cache_control": {"type": "ephemeral"},
"prompt_cache_breakpoint": {"mode": "explicit", "ttl": "30m"},
}
assert cache_control_as_prompt_cache_breakpoint(both) == {
"type": "input_text",
"text": "rules",
"prompt_cache_breakpoint": {"mode": "explicit", "ttl": "30m"},
}

View file

@ -1,5 +1,5 @@
import json
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from types import SimpleNamespace
from typing import Final, cast
from unittest.mock import AsyncMock, MagicMock, Mock, patch
@ -2303,6 +2303,314 @@ class TestToolSchemaRegexPatternWiring:
assert result["tools"][0] is tool
_OPENAI_API: Final = GenericLiteLLMParams(api_base="https://api.openai.com/v1")
_OTHER_HOST: Final = GenericLiteLLMParams(api_base="https://gateway.example/v1")
_EXPLICIT_OPT_IN: Final = {"prompt_cache_options": {"mode": "explicit"}}
_EPHEMERAL: Final = {"type": "ephemeral"}
_EXPLICIT_BREAKPOINT: Final = {"mode": "explicit"}
def _marked_input(cache_control: dict[str, str] | None) -> list[dict[str, object]]:
mark: Final = {} if cache_control is None else {"cache_control": cache_control}
return [
{
"role": "developer",
"content": [
{"type": "input_text", "text": "Be concise."},
{"type": "input_text", "text": "Be helpful.", **mark},
],
},
{"type": "message", "role": "user", "content": "look", **mark},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "looking", **mark}]},
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "found", **mark},
{"type": "function_call", "call_id": "call_2", "name": "lookup", "arguments": "{}"},
{
"type": "function_call_output",
"call_id": "call_2",
"output": [{"type": "input_text", "text": "first"}, {"type": "input_text", "text": "last"}],
**mark,
},
]
def _marked_tool(cache_control: dict[str, str] | None) -> dict[str, object]:
mark: Final = {} if cache_control is None else {"cache_control": cache_control}
return {"type": "function", "name": "lookup", "parameters": {"type": "object", "properties": {}}, **mark}
def _openai_like_responses_config() -> OpenAIResponsesAPIConfig:
from litellm.llms.openai_like.responses.transformation import OpenAILikeResponsesConfig
return OpenAILikeResponsesConfig()
def _generated_sail_responses_config() -> OpenAIResponsesAPIConfig:
from litellm.llms.openai_like.dynamic_config import create_responses_config_class
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
sail = JSONProviderRegistry.get("sail")
assert sail is not None
return create_responses_config_class(sail)()
class TestCacheControlBecomesPromptCacheBreakpoint:
"""Clients that speak Anthropic's dialect mark blocks with `cache_control`. On a deployment that
reads OpenAI's `prompt_cache_breakpoint` the mark moves onto the same block instead of being
stripped, so explicit-mode prompt caching keeps working through the proxy."""
@pytest.fixture(autouse=True)
def _no_openai_api_base_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setattr(litellm, "api_base", None)
def _transform(
self,
model: str,
litellm_params: GenericLiteLLMParams,
cache_control: dict[str, str] | None,
extra_params: dict[str, object] | None = None,
config: OpenAIResponsesAPIConfig | None = None,
) -> dict[str, object]:
return (config or OpenAIResponsesAPIConfig()).transform_responses_api_request(
model=model,
input=_marked_input(cache_control),
response_api_optional_request_params={"tools": [_marked_tool(cache_control)], **(extra_params or {})},
litellm_params=litellm_params,
headers={},
)
@pytest.mark.parametrize(
("litellm_params", "extra_params"),
[
pytest.param(_OPENAI_API, {}, id="api.openai.com"),
pytest.param(
GenericLiteLLMParams(base_url="https://api.openai.com/v1"), {}, id="api.openai.com-as-base_url"
),
pytest.param(GenericLiteLLMParams(), {}, id="default-host"),
pytest.param(_OTHER_HOST, _EXPLICIT_OPT_IN, id="other-host-opted-in"),
],
)
def test_marks_reach_gpt_5_6_as_explicit_breakpoints_on_the_same_blocks(
self, litellm_params: GenericLiteLLMParams, extra_params: dict[str, object]
) -> None:
result = self._transform("gpt-5.6", litellm_params, {**_EPHEMERAL, "ttl": "1h"}, extra_params)
assert result["input"] == [
{
"role": "developer",
"content": [
{"type": "input_text", "text": "Be concise."},
{"type": "input_text", "text": "Be helpful.", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT},
],
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "look", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT}],
},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "looking"}]},
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{
"type": "function_call_output",
"call_id": "call_1",
"output": [{"type": "input_text", "text": "found", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT}],
},
{"type": "function_call", "call_id": "call_2", "name": "lookup", "arguments": "{}"},
{
"type": "function_call_output",
"call_id": "call_2",
"output": [
{"type": "input_text", "text": "first"},
{"type": "input_text", "text": "last", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT},
],
},
]
assert result["tools"] == [_marked_tool(None)]
assert result.get("prompt_cache_options") == extra_params.get("prompt_cache_options")
def test_per_part_marks_win_over_the_item_mark_and_keep_their_own_block(self) -> None:
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-5.6",
input=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "a", "cache_control": _EPHEMERAL},
{"type": "input_image", "image_url": "https://img.example/x.png", "detail": "auto"},
{"type": "input_text", "text": "b"},
],
"cache_control": _EPHEMERAL,
}
],
response_api_optional_request_params={},
litellm_params=_OPENAI_API,
headers={},
)
assert result["input"] == [
{
"role": "user",
"content": [
{"type": "input_text", "text": "a", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT},
{"type": "input_image", "image_url": "https://img.example/x.png", "detail": "auto"},
{"type": "input_text", "text": "b", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT},
],
}
]
@pytest.mark.parametrize(
("model", "litellm_params"),
[
pytest.param("gpt-4o", _OPENAI_API, id="model-older-than-gpt-5.6"),
pytest.param("gpt-5.6", _OTHER_HOST, id="other-host-not-opted-in"),
pytest.param(
"gpt-5.6", GenericLiteLLMParams(base_url="https://gateway.example/v1"), id="other-host-as-base_url"
),
],
)
def test_marks_are_still_stripped_where_the_deployment_does_not_read_breakpoints(
self, model: str, litellm_params: GenericLiteLLMParams
) -> None:
marked = self._transform(model, litellm_params, _EPHEMERAL)
unmarked = self._transform(model, litellm_params, None)
assert marked == unmarked
assert "prompt_cache_breakpoint" not in json.dumps(marked)
def test_non_openai_subclass_still_strips_on_a_gpt_5_6_deployment(self) -> None:
from litellm.llms.hosted_vllm.responses.transformation import HostedVLLMResponsesAPIConfig
config = HostedVLLMResponsesAPIConfig()
marked = self._transform("gpt-5.6", _OPENAI_API, _EPHEMERAL, _EXPLICIT_OPT_IN, config=config)
unmarked = self._transform("gpt-5.6", _OPENAI_API, None, _EXPLICIT_OPT_IN, config=config)
assert marked == unmarked
assert "prompt_cache_breakpoint" not in json.dumps(marked["input"])
@pytest.mark.parametrize(
"make_config", [_openai_like_responses_config, _generated_sail_responses_config], ids=["openai_like", "sail"]
)
def test_a_config_naming_its_provider_as_a_plain_str_still_strips_on_a_gpt_5_6_deployment(
self, make_config: Callable[[], OpenAIResponsesAPIConfig]
) -> None:
config = make_config()
assert type(config.custom_llm_provider) is str
marked = self._transform("gpt-5.6", _OPENAI_API, _EPHEMERAL, _EXPLICIT_OPT_IN, config=config)
unmarked = self._transform("gpt-5.6", _OPENAI_API, None, _EXPLICIT_OPT_IN, config=config)
assert marked == unmarked
assert "prompt_cache_breakpoint" not in json.dumps(marked["input"])
def test_a_breakpoint_the_client_wrote_on_the_same_block_wins_over_the_mark_ttl_included(self) -> None:
kept = {"mode": "explicit", "ttl": "30m"}
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-5.6",
input=[
{
"role": "user",
"content": [
{
"type": "input_text",
"text": "a",
"prompt_cache_breakpoint": kept,
"cache_control": _EPHEMERAL,
}
],
},
{
"role": "user",
"content": [{"type": "input_text", "text": "b", "prompt_cache_breakpoint": kept}],
"cache_control": _EPHEMERAL,
},
],
response_api_optional_request_params={},
litellm_params=_OPENAI_API,
headers={},
)
assert result["input"] == [
{"role": "user", "content": [{"type": "input_text", "text": "a", "prompt_cache_breakpoint": kept}]},
{"role": "user", "content": [{"type": "input_text", "text": "b", "prompt_cache_breakpoint": kept}]},
]
def test_marks_on_parts_openai_does_not_cache_are_dropped_and_the_item_mark_skips_them(self) -> None:
audio = {"type": "input_audio", "input_audio": {"data": "UklGRg==", "format": "wav"}}
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-5.6",
input=[
{"role": "user", "content": [{**audio, "cache_control": _EPHEMERAL}], "cache_control": _EPHEMERAL},
{
"role": "user",
"content": [{"type": "input_text", "text": "a"}, {**audio, "cache_control": _EPHEMERAL}],
"cache_control": _EPHEMERAL,
},
],
response_api_optional_request_params={},
litellm_params=_OPENAI_API,
headers={},
)
assert result["input"] == [
{"role": "user", "content": [audio]},
{
"role": "user",
"content": [
{"type": "input_text", "text": "a", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT},
audio,
],
},
]
def test_items_and_content_of_a_shape_openai_will_reject_pass_through_to_the_strip(self) -> None:
"""Validating the body is upstream's job. The swap leaves what it cannot read alone, so the
caller gets OpenAI's 400 for it instead of a failure inside the proxy."""
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-5.6",
input=[
"a bare string where an item belongs",
{
"type": "function_call_output",
"call_id": "call_1",
"output": {"result": 1},
"cache_control": _EPHEMERAL,
},
{"role": "user", "content": "look", "cache_control": _EPHEMERAL},
],
response_api_optional_request_params={},
litellm_params=_OPENAI_API,
headers={},
)
assert result["input"] == [
"a bare string where an item belongs",
{"type": "function_call_output", "call_id": "call_1", "output": {"result": 1}},
{
"role": "user",
"content": [{"type": "input_text", "text": "look", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT}],
},
]
def test_compact_request_swaps_marks_the_same_way(self) -> None:
_, data = OpenAIResponsesAPIConfig().transform_compact_response_api_request(
model="gpt-5.6",
input=[{"role": "user", "content": "look", "cache_control": _EPHEMERAL}],
response_api_optional_request_params=dict(_EXPLICIT_OPT_IN),
litellm_params=_OTHER_HOST,
headers={},
api_base="https://gateway.example/v1/responses",
)
assert data["input"] == [
{
"role": "user",
"content": [{"type": "input_text", "text": "look", "prompt_cache_breakpoint": _EXPLICIT_BREAKPOINT}],
}
]
class TestReasoningFollowsModelSupport:
"""Responses API clients like Codex send `reasoning` on every request, and OpenAI 400s it
on non-reasoning models like gpt-4o. drop_params must strip it there, the same way the