fix(completion): send prompt_cache_retention to OpenAI instead of silently dropping it (#47480)

* fix(completion): send prompt_cache_retention to OpenAI instead of silently dropping it

litellm.completion(), Router.completion() and the proxy's /v1/chat/completions
accepted prompt_cache_retention and listed it as a supported OpenAI param, but
completion() never handed it to get_optional_params, and the leftover-kwargs
paths skip known OpenAI params, so it never reached the request body. Make it a
named argument of completion() and acompletion() like prompt_cache_key and pass
it on.

A unit guard sends every OpenAI body param through completion() and fails when a
param added to OPENAI_CHAT_COMPLETION_PARAMS has no case proving it reaches the
wire, the same hole store and prompt_cache_key fell into in #33184. Live e2e
tests capture the body OpenAI receives for the proxy, the SDK and the Router on
both openai/gpt-5.5 and openai/responses/gpt-5.5.

* chore(completion): type the param-forwarding unit test and keep the basedpyright count flat

Every new completion() kwarg adds one unknown-argument error at each call
site that splats an untyped dict into it; typing print_verbose's argument
and Chat's params removes more than prompt_cache_retention added.

* test: freeze the outbound-body model and read captured requests from the mock

Addresses review: the e2e body model is frozen, the backend params are a
tuple, and the unit test reads the request from the respx route instead of
appending to a list.
This commit is contained in:
ryan-crabbe-berri 2026-10-20 09:41:22 -07:00 • committed by GitHub
parent 8d2bf426f5
commit 9bbf70edb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 275 additions and 5 deletions

View file

@ -400,7 +400,11 @@ class LiteLLM:
class Chat:
def __init__(self, params, router_obj: "Router | None"):
def __init__(
self,
params: dict[str, object], # mutable-ok: pops the acompletion flag in place
router_obj: "Router | None",
):
self.params = params
if self.params.get("acompletion", False) is True:
self.params.pop("acompletion")
@ -480,6 +484,7 @@ async def acompletion(
service_tier: str | None = None,
store: bool | None = None,
prompt_cache_key: str | None = None,
prompt_cache_retention: str | None = None,
# set api_base, api_version, api_key
base_url: str | None = None,
api_version: str | None = None,
@ -657,6 +662,7 @@ async def acompletion(
"service_tier": service_tier,
"store": store,
"prompt_cache_key": prompt_cache_key,
"prompt_cache_retention": prompt_cache_retention,
"extra_headers": extra_headers,
"acompletion": True, # assuming this is a required parameter
"thinking": thinking,
@ -5356,6 +5362,7 @@ def completion(
service_tier: str | None = None,
store: bool | None = None,
prompt_cache_key: str | None = None,
prompt_cache_retention: str | None = None,
# soon to be deprecated params by OpenAI
functions: list | None = None,
function_call: str | None = None,
@ -5478,6 +5485,7 @@ def completion(
service_tier=service_tier,
store=store,
prompt_cache_key=prompt_cache_key,
prompt_cache_retention=prompt_cache_retention,
base_url=base_url,
api_version=api_version,
api_key=api_key,
@ -5843,6 +5851,7 @@ def completion(
"service_tier": service_tier,
"store": store,
"prompt_cache_key": prompt_cache_key,
"prompt_cache_retention": prompt_cache_retention,
"allowed_openai_params": allowed_openai_params,
"base_model": base_model,
}
@ -9143,7 +9152,7 @@ async def ahealth_check(
####### HELPER FUNCTIONS ################
## Set verbose to true -> ```litellm.set_verbose = True```
def print_verbose(print_statement):
def print_verbose(print_statement: object):
try:
verbose_logger.debug(print_statement)
if litellm.set_verbose:

View file

@ -13,6 +13,8 @@
- {id: llm.chat_completions.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "gpt-4o vision; high usage"}
- {id: llm.chat_completions.openai.prompt_cache_5m.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_5m, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Prompt caching cost optimization"}
- {id: llm.chat_completions.openai.service_tier.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: service_tier, streaming: nonstream, assertions: [works], source: "OpenAI service_tier param", rationale: "OpenAI scale-tier request option is forwarded and echoed"}
- {id: llm.chat_completions.openai.prompt_cache_24h.nonstream.forwards_retention, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_24h, streaming: nonstream, assertions: [forwards_retention], source: "main.py completion()", fail_before_fix: proven, rationale: "prompt_cache_retention sent through the proxy, litellm.completion or Router.completion reaches the body sent to OpenAI /v1/chat/completions, so a caller asking for 24h prompt caching gets it instead of a silent drop"}
- {id: llm.chat_completions.openai.prompt_cache_24h.nonstream.bridge_forwards_retention, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_24h, streaming: nonstream, assertions: [forwards_retention], source: "main.py completion()", fail_before_fix: proven, rationale: "prompt_cache_retention on an openai/responses/ model (chat completions in, Responses out) reaches the body sent to OpenAI /v1/responses"}
- {id: llm.chat_completions.openai.service_tier.stream.echoes_served_tier, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: service_tier, streaming: stream, assertions: [works], source: "litellm_core_utils/streaming_handler.py", fail_before_fix: proven, rationale: "Every relayed stream chunk carries the service_tier OpenAI stamped on it, so a streaming caller can see which tier served the request"}
- {id: llm.chat_completions.openai.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "o-series reasoning; emerging"}
- {id: llm.chat_completions.openai.structured_output.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: structured_output, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "response_schema extraction"}

View file

@ -81,6 +81,7 @@ LlmCapability = Literal[
"pdf_input",
"logprobs",
"prompt_cache_1h",
"prompt_cache_24h",
"prompt_cache_5m",
"response_headers",
"service_tier",

View file

@ -1,4 +1,4 @@
"""Live e2e for model-specific request features: service_tier.
"""Live e2e for model-specific request features: service_tier and prompt_cache_retention.
Each case asserts the feature took effect, not just a 200.
@ -11,26 +11,38 @@ avoided here because it is capacity-constrained and returns a transient 429 when
flex resources are unavailable. Bedrock and Vertex do not accept service_tier, so
that cell is OpenAI-only by design.
Prompt caching lives in test_cache_control.py.
prompt_cache_retention is judged on the body OpenAI receives, captured by a live
provider edge, not on the reply: OpenAI echoes "24h" on gpt-5.5 even when the
request never carried the param, so an echo would pass with the param dropped.
Prompt caching hits and misses live in test_cache_control.py.
"""
from __future__ import annotations
import queue
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Final
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_config import PROVIDER_EDGE_ADVERTISE_HOST, PROVIDER_EDGE_BIND_HOST, unique_marker
from e2e_http import unwrap
from e2e_metadata import Domain, Mode, Provider, Route, Subject, meta
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, LiteLLMParamsBody
from passthrough_client import PassthroughClient
from provider_edge import LiveEdge, start_provider_edge
from proxy_client import ProxyClient
pytestmark = pytest.mark.e2e
SERVICE_TIER = "priority"
OPENAI_BACKEND: Final = "openai/gpt-5.5"
OPENAI_RESPONSES_BRIDGE_BACKEND: Final = "openai/responses/gpt-5.5"
PROMPT_CACHE_RETENTION: Final = "24h"
class TestServiceTier:
@ -74,3 +86,153 @@ class TestServiceTier:
f"service_tier not honored: sent {SERVICE_TIER!r}, response reported "
f"{response.service_tier!r} ({response})"
)
class _OutboundRetention(BaseModel, frozen=True):
prompt_cache_retention: str | None = None
@dataclass(frozen=True, slots=True)
class _OpenAIBodyCapture:
"""The raw bodies sent to one OpenAI path, seen by a live edge in front of
api.openai.com. Pass ``observe`` as the edge's ``observe_request``."""
path: str
_bodies: queue.SimpleQueue[bytes] = field(default_factory=queue.SimpleQueue)
def observe(self, url: str, headers: Mapping[str, str], body: bytes | None) -> None:
if body is not None and url.split("?")[0].endswith(self.path):
self._bodies.put(body)
def retentions(self) -> tuple[str | None, ...]:
bodies: Final = tuple(self._bodies.get_nowait() for _ in range(self._bodies.qsize()))
return tuple(_OutboundRetention.model_validate_json(body).prompt_cache_retention for body in bodies)
def _openai_edge_api_base(resources: ResourceManager, capture: _OpenAIBodyCapture, *, reachable_from_proxy: bool) -> str:
"""The proxy may run in a container, so its edge binds where the proxy can
reach it; an in-process SDK call reaches the edge on loopback."""
edge: Final = (
start_provider_edge(
LiveEdge(observe_request=capture.observe),
bind_host=PROVIDER_EDGE_BIND_HOST,
advertise_host=PROVIDER_EDGE_ADVERTISE_HOST,
)
if reachable_from_proxy
else start_provider_edge(LiveEdge(observe_request=capture.observe))
)
resources.defer(edge.shutdown)
return f"{edge.edge.api_base('openai')}/v1"
def _retention_backends() -> tuple[object, ...]:
"""gpt-5.5 straight to /v1/chat/completions, and the openai/responses/ bridge
(chat completions in, Responses out) to /v1/responses."""
return (
pytest.param(
OPENAI_BACKEND,
"/chat/completions",
marks=(
pytest.mark.covers("llm.chat_completions.openai.prompt_cache_24h.nonstream.forwards_retention"),
meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.OPENAI,),
models=(OPENAI_BACKEND,),
mode=Mode.NONSTREAM,
)
),
),
id="chat_completions",
),
pytest.param(
OPENAI_RESPONSES_BRIDGE_BACKEND,
"/responses",
marks=(
pytest.mark.covers("llm.chat_completions.openai.prompt_cache_24h.nonstream.bridge_forwards_retention"),
meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.CHAT_COMPLETIONS,
providers=(Provider.OPENAI,),
models=(OPENAI_RESPONSES_BRIDGE_BACKEND,),
mode=Mode.NONSTREAM,
)
),
),
id="responses_bridge",
),
)
class TestPromptCacheRetention:
@pytest.mark.provider_edge_host
@pytest.mark.parametrize(("backend", "openai_path"), _retention_backends())
def test_proxy_chat_completions_forwards_prompt_cache_retention_to_openai(
self, proxy: ProxyClient, resources: ResourceManager, backend: str, openai_path: str
) -> None:
capture: Final = _OpenAIBodyCapture(openai_path)
api_base: Final = _openai_edge_api_base(resources, capture, reachable_from_proxy=True)
model: Final = f"e2e-cache-retention-{unique_marker()}"
model_id: Final = proxy.create_model(
model, LiteLLMParamsBody(model=backend, api_key="os.environ/OPENAI_API_KEY", api_base=api_base)
)
resources.defer(lambda: proxy.delete_model(model_id))
unwrap(
proxy.chat(
resources.key(),
ChatBody(
model=model,
messages=[ChatMessage(role="user", content="reply with one word")],
prompt_cache_retention=PROMPT_CACHE_RETENTION,
),
)
)
assert capture.retentions() == (PROMPT_CACHE_RETENTION,), (
f"one {openai_path} call to OpenAI must carry prompt_cache_retention={PROMPT_CACHE_RETENTION!r}"
)
@pytest.mark.parametrize(("backend", "openai_path"), _retention_backends())
def test_sdk_completion_forwards_prompt_cache_retention_to_openai(
self, resources: ResourceManager, backend: str, openai_path: str
) -> None:
import litellm
capture: Final = _OpenAIBodyCapture(openai_path)
api_base: Final = _openai_edge_api_base(resources, capture, reachable_from_proxy=False)
litellm.completion(
model=backend,
messages=[{"role": "user", "content": "reply with one word"}],
api_base=api_base,
prompt_cache_retention=PROMPT_CACHE_RETENTION,
)
assert capture.retentions() == (PROMPT_CACHE_RETENTION,), (
f"one {openai_path} call to OpenAI must carry prompt_cache_retention={PROMPT_CACHE_RETENTION!r}"
)
@pytest.mark.parametrize(("backend", "openai_path"), _retention_backends())
def test_router_completion_forwards_prompt_cache_retention_to_openai(
self, resources: ResourceManager, backend: str, openai_path: str
) -> None:
import litellm
capture: Final = _OpenAIBodyCapture(openai_path)
api_base: Final = _openai_edge_api_base(resources, capture, reachable_from_proxy=False)
router: Final = litellm.Router(
model_list=[{"model_name": "cache-retention", "litellm_params": {"model": backend, "api_base": api_base}}]
)
router.completion( # pyright: ignore[reportUnknownMemberType] # Router.completion forwards an untyped **kwargs to litellm.completion
model="cache-retention",
messages=[{"role": "user", "content": "reply with one word"}],
prompt_cache_retention=PROMPT_CACHE_RETENTION,
)
assert capture.retentions() == (PROMPT_CACHE_RETENTION,), (
f"one {openai_path} call to OpenAI must carry prompt_cache_retention={PROMPT_CACHE_RETENTION!r}"
)

View file

@ -364,6 +364,7 @@ class ChatBody(BaseModel):
thinking: ThinkingParam | None = None
service_tier: str | None = None
prompt_cache_key: str | None = None
prompt_cache_retention: str | None = None
tools: Sequence[ChatTool | McpChatTool | GeminiUrlContextTool] | None = None
tool_choice: str | None = None
guardrails: list[str] | None = None

View file

@ -23,6 +23,7 @@ import respx
from openai import APITimeoutError, AsyncOpenAI
from openai._legacy_response import LegacyAPIResponse
from openai.types.chat.chat_completion import ChatCompletion
from pydantic import TypeAdapter
import litellm
from litellm import (
@ -3937,6 +3938,100 @@ async def test_acompletion_forwards_store_and_prompt_cache_key_to_openai():
assert request_body["prompt_cache_key"] == "test-cache-key"
_TOOL: Final[Mapping[str, object]] = {"type": "function", "function": {"name": "f", "parameters": {"type": "object", "properties": {}}}}
# Client settings ride in OPENAI_CHAT_COMPLETION_PARAMS but configure the HTTP client, never the body.
_OPENAI_CLIENT_SETTINGS: Final = frozenset(
{
"api_base",
"api_key",
"api_version",
"base_url",
"default_headers",
"deployment_id",
"extra_headers",
"max_retries",
"organization",
"request_timeout",
"timeout",
}
)
# Accepted by completion() for other providers, never sent to OpenAI's /chat/completions body.
_NOT_OPENAI_BODY_PARAMS: Final = frozenset({"include_server_side_tool_invocations", "thinking"})
# param -> (model that accepts it, the call kwargs that carry it)
_OPENAI_BODY_PARAM_CASES: Final[Mapping[str, tuple[str, Mapping[str, object]]]] = {
"audio": ("openai/gpt-4o", {"audio": {"voice": "alloy", "format": "wav"}}),
"frequency_penalty": ("openai/gpt-4o", {"frequency_penalty": 0.5}),
"function_call": ("openai/gpt-4o", {"function_call": "auto"}),
"functions": ("openai/gpt-4o", {"functions": [_TOOL["function"]]}),
"logit_bias": ("openai/gpt-4o", {"logit_bias": {"1": 1}}),
"logprobs": ("openai/gpt-4o", {"logprobs": True}),
"max_completion_tokens": ("openai/gpt-4o", {"max_completion_tokens": 7}),
"max_tokens": ("openai/gpt-4o", {"max_tokens": 7}),
"modalities": ("openai/gpt-4o", {"modalities": ["text"]}),
"n": ("openai/gpt-4o", {"n": 2}),
"parallel_tool_calls": ("openai/gpt-4o", {"tools": [_TOOL], "parallel_tool_calls": False}),
"prediction": ("openai/gpt-4o", {"prediction": {"type": "content", "content": "x"}}),
"presence_penalty": ("openai/gpt-4o", {"presence_penalty": 0.5}),
"prompt_cache_key": ("openai/gpt-4o", {"prompt_cache_key": "k"}),
"prompt_cache_retention": ("openai/gpt-5.5", {"prompt_cache_retention": "24h"}),
"reasoning_effort": ("openai/gpt-5.5", {"reasoning_effort": "low"}),
"response_format": ("openai/gpt-4o", {"response_format": {"type": "json_object"}}),
"safety_identifier": ("openai/gpt-4o", {"safety_identifier": "s"}),
"seed": ("openai/gpt-4o", {"seed": 3}),
"service_tier": ("openai/gpt-4o", {"service_tier": "priority"}),
"stop": ("openai/gpt-4o", {"stop": ["x"]}),
"store": ("openai/gpt-4o", {"store": False}),
"stream": ("openai/gpt-4o", {"stream": True}),
"stream_options": ("openai/gpt-4o", {"stream": True, "stream_options": {"include_usage": True}}),
"temperature": ("openai/gpt-4o", {"temperature": 0.5}),
"tool_choice": ("openai/gpt-4o", {"tools": [_TOOL], "tool_choice": "auto"}),
"tools": ("openai/gpt-4o", {"tools": [_TOOL]}),
"top_logprobs": ("openai/gpt-4o", {"logprobs": True, "top_logprobs": 2}),
"top_p": ("openai/gpt-4o", {"top_p": 0.5}),
"user": ("openai/gpt-4o", {"user": "u1"}),
"verbosity": ("openai/gpt-5.5", {"verbosity": "low"}),
"web_search_options": ("openai/gpt-4o", {"web_search_options": {"search_context_size": "low"}}),
}
def test_every_openai_body_param_has_a_forwarding_case():
body_params: Final = frozenset(litellm.OPENAI_CHAT_COMPLETION_PARAMS) - _OPENAI_CLIENT_SETTINGS - _NOT_OPENAI_BODY_PARAMS
assert frozenset(_OPENAI_BODY_PARAM_CASES) == body_params, (
"a param added to OPENAI_CHAT_COMPLETION_PARAMS needs a case in _OPENAI_BODY_PARAM_CASES proving "
"completion() sends it to OpenAI; prompt_cache_retention (LIT-9758) and store/prompt_cache_key "
"(#33184) were listed as supported but silently dropped"
)
_REQUEST_BODY: Final = TypeAdapter(dict[str, object])
@pytest.mark.parametrize("param", sorted(_OPENAI_BODY_PARAM_CASES))
def test_completion_sends_openai_body_param_to_openai(
param: str, respx_mock: respx.MockRouter, openai_api_response: Mapping[str, object]
):
api_base: Final = "http://localhost:12346/v1"
route: Final = respx_mock.post(f"{api_base}/chat/completions").mock(
return_value=httpx.Response(status_code=200, json=openai_api_response)
)
model, call_kwargs = _OPENAI_BODY_PARAM_CASES[param]
litellm.completion(
model=model,
messages=[{"role": "user", "content": "hi"}],
api_base=api_base,
api_key="fake_openai_api_key",
**call_kwargs,
)
assert route.call_count == 1
sent: Final = _REQUEST_BODY.validate_json(route.calls.last.request.content)
assert sent.get(param) == call_kwargs[param], f"{param} did not reach the OpenAI request body: {sent}"
def test_completion_omits_store_and_prompt_cache_key_when_not_passed():
"""
When store and prompt_cache_key are not passed, they must not appear in the