From 1bbb32b6972edc0676debe48c3373c43f1fedba0 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 31 Oct 2026 03:43:23 +0000 Subject: [PATCH] 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> --- .../llms/openai/responses/transformation.py | 31 +++++++++++++- litellm/proxy/hooks/responses_id_security.py | 41 +++++++++++++++++++ litellm/types/llms/openai.py | 1 + 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 9b8515a9ecd..942558b1118 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -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, diff --git a/litellm/proxy/hooks/responses_id_security.py b/litellm/proxy/hooks/responses_id_security.py index 771d68a0ea0..cacf9d27d4b 100644 --- a/litellm/proxy/hooks/responses_id_security.py +++ b/litellm/proxy/hooks/responses_id_security.py @@ -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, diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index a54d66c4beb..cde0db6fac8 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -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):