diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 315fbcba310..b833eeaefda 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -3165,6 +3165,8 @@ class ProxyBaseLLMRequestProcessing: ): """Raises ProxyException (OpenAI API compatible) if an exception is raised""" _log_llm_api_exception(e) + # Capture before post_call_failure_hook pops it from the request data + _litellm_logging_obj: Final[LiteLLMLoggingObj | None] = self.data.get("litellm_logging_obj", None) # Allow callbacks to transform the error response transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, @@ -3184,7 +3186,6 @@ class ProxyBaseLLMRequestProcessing: timeout: Final = getattr( e, "timeout", None ) # returns the timeout set by the wrapper. Used for testing if model-specific timeout are set correctly - _litellm_logging_obj: Final[LiteLLMLoggingObj | None] = self.data.get("litellm_logging_obj", None) # Attempt to get model_id from logging object # @@ -3789,10 +3790,12 @@ class ProxyBaseLLMRequestProcessing: model_info = metadata.get("model_info") or {} model_id = model_info.get("id", None) - # 3. Final fallback to self.data["litellm_metadata"] (for routes like /v1/responses that populate data before error) + # 3. Final fallback to request metadata (the router stamps the selected + # deployment's model_info there; "litellm_metadata" covers routes like /v1/responses) if not model_id: - litellm_metadata: Final = self.data.get("litellm_metadata", {}) or {} - model_info = litellm_metadata.get("model_info", {}) or {} - model_id = model_info.get("id", None) + fallback_model_infos: Final = ( + (self.data.get(key) or {}).get("model_info") or {} for key in ("metadata", "litellm_metadata") + ) + model_id = next((info.get("id") for info in fallback_model_infos if info.get("id")), None) return model_id diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 64318778bc2..4e2a14c965c 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -2855,6 +2855,42 @@ class TestHandleLLMApiExceptionDictDetail: proxy_exc = await self._invoke(exc) assert proxy_exc.code == "500" + async def test_error_headers_keep_model_id_after_failure_hook_pops_logging_obj(self): + """post_call_failure_hook pops litellm_logging_obj from the request data; + the exception handler must capture it first so error responses (e.g. 429s + on /v1/embeddings) still carry x-litellm-model-id. Regression for the + embeddings deployment-id report (2026-08-19).""" + from litellm.proxy._types import ProxyException, UserAPIKeyAuth + + logging_obj = MagicMock() + logging_obj.litellm_call_id = "call-abc" + logging_obj.litellm_params = {"model_info": {"id": "my-embed-deployment-id"}} + data = {"litellm_logging_obj": logging_obj} + processor = ProxyBaseLLMRequestProcessing(data=data) + + async def popping_hook(user_api_key_dict, original_exception, request_data): + request_data.pop("litellm_logging_obj", None) + return None + + proxy_logging_obj = MagicMock() + proxy_logging_obj.post_call_failure_hook = AsyncMock(side_effect=popping_hook) + proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={}) + + with pytest.raises(ProxyException) as exc_info: + await processor._handle_llm_api_exception( + e=ValueError("provider 429"), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + proxy_logging_obj=proxy_logging_obj, + ) + assert exc_info.value.headers["x-litellm-model-id"] == "my-embed-deployment-id" + assert exc_info.value.headers["x-litellm-call-id"] == "call-abc" + + async def test_maybe_get_model_id_falls_back_to_request_metadata(self): + """The router stamps the selected deployment's model_info into the request + metadata; without a logging object that is the last place the id survives.""" + processor = ProxyBaseLLMRequestProcessing(data={"metadata": {"model_info": {"id": "dep-from-metadata"}}}) + assert processor.maybe_get_model_id(None) == "dep-from-metadata" + async def test_already_normalized_proxy_exception_is_honored(self): """A ProxyException raised mid-request (e.g. a guardrail block) is already the OpenAI wire format. The funnel must re-raise it untouched instead of