fix(responses): unwrap and authorize prompt_cache_options.comparison_response_id like previous_response_id

The proxy now decrypts an encrypted comparison_response_id and applies the same user and team ownership check previous_response_id gets, on top-level and extra_body prompt_cache_options. The native OpenAI Responses transform decodes the routing wrapper so OpenAI receives its own response id. Raw ids pass through unchanged

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-10-31 03:43:23 +00:00
parent b92222922a
commit 1bbb32b697
3 changed files with 72 additions and 1 deletions

View file

@ -15,7 +15,7 @@ from litellm.integrations.anthropic_cache_control_hook import (
openai_prompt_cache_breakpoint_support,
targets_openai_api,
)
from litellm.litellm_core_utils.core_helpers import normalize_drop_params, process_response_headers
from litellm.litellm_core_utils.core_helpers import as_str_mapping, normalize_drop_params, 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,
@ -44,6 +44,7 @@ from litellm.responses.litellm_completion_transformation.tool_search import (
tools_with_anthropic_tool_search_mapped,
tools_without_anthropic_tool_search,
)
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.llms.openai import *
@ -623,11 +624,39 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
"model": model,
"input": replay_safe_input,
**response_api_optional_request_params,
**self._prompt_cache_options_with_original_comparison_id(request_params),
},
)
return final_request_params
def transform_extra_body(
self,
extra_body: Mapping[str, object],
request: Mapping[str, object],
model: str,
litellm_params: GenericLiteLLMParams,
) -> Mapping[str, object]:
return {**extra_body, **self._prompt_cache_options_with_original_comparison_id(extra_body)}
def _prompt_cache_options_with_original_comparison_id(self, params: Mapping[str, object]) -> Mapping[str, object]:
options: Final = as_str_mapping(params.get("prompt_cache_options"))
comparison_response_id: Final = None if options is None else options.get("comparison_response_id")
if (
self.custom_llm_provider != LlmProviders.OPENAI
or options is None
or not isinstance(comparison_response_id, str)
):
return {}
original_response_id: Final = (
ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(
comparison_response_id
)
)
if original_response_id == comparison_response_id:
return {}
return {"prompt_cache_options": {**options, "comparison_response_id": original_response_id}}
def _prepared_input_and_tools(
self,
model: str,

View file

@ -14,6 +14,7 @@ from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
@ -135,6 +136,9 @@ class ResponsesIDSecurity(CustomLogger):
addressed_id_field: Final = _ADDRESSED_RESPONSE_ID_FIELD_BY_CALL_TYPE.get(call_type)
if addressed_id_field is None:
return None
if call_type == "aresponses":
request_data: Final = cast("dict[str, object]", data) # cast-ok: the hook signature leaves the body untyped
request_data.update(self._authorized_comparison_response_id_fields(request_data, user_api_key_dict))
retained_id: Final = data.get(ADDRESSED_RESPONSE_ID_FIELD)
addressed_id: Final = (
retained_id if isinstance(retained_id, str) and retained_id else data.get(addressed_id_field)
@ -146,6 +150,43 @@ class ResponsesIDSecurity(CustomLogger):
data[ADDRESSED_RESPONSE_ID_FIELD] = addressed_id
return data
def _authorized_comparison_response_id_fields(
self,
data: Mapping[str, object],
user_api_key_dict: "UserAPIKeyAuth",
) -> Mapping[str, object]:
options: Final = self._authorized_prompt_cache_options(data.get("prompt_cache_options"), user_api_key_dict)
extra_body: Final = as_str_mapping(data.get("extra_body"))
extra_body_options: Final = (
None
if extra_body is None
else self._authorized_prompt_cache_options(extra_body.get("prompt_cache_options"), user_api_key_dict)
)
options_field: Final[Mapping[str, object]] = {} if options is None else {"prompt_cache_options": options}
extra_body_field: Final[Mapping[str, object]] = (
{}
if extra_body is None or extra_body_options is None
else {"extra_body": {**extra_body, "prompt_cache_options": extra_body_options}}
)
return {**options_field, **extra_body_field}
def _authorized_prompt_cache_options(
self,
prompt_cache_options: object,
user_api_key_dict: "UserAPIKeyAuth",
) -> Mapping[str, object] | None:
options: Final = as_str_mapping(prompt_cache_options)
comparison_response_id: Final = None if options is None else options.get("comparison_response_id")
if (
options is None
or not isinstance(comparison_response_id, str)
or not self._is_encrypted_response_id(comparison_response_id)
):
return None
original_response_id, user_id, team_id = self._decrypt_response_id(comparison_response_id)
self.check_user_access_to_response_id(user_id, team_id, user_api_key_dict)
return {**options, "comparison_response_id": original_response_id}
def authorize_response_id(
self,
response_id: str,

View file

@ -689,6 +689,7 @@ class PromptCacheBreakpoint(TypedDict):
class PromptCacheOptions(TypedDict, total=False):
mode: ReadOnly[Literal["implicit", "explicit"]]
ttl: ReadOnly[Literal["30m", "12h"]]
comparison_response_id: ReadOnly[str]
class ChatCompletionThinkingBlock(TypedDict, total=False):