diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index c7c9397d850..1dc0ee3f947 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -925,9 +925,12 @@ class ProxyBaseLLMRequestProcessing: # If conversion fails, use original spend pass + model_name = ProxyBaseLLMRequestProcessing._get_deployment_model_name(litellm_logging_obj) + headers = { "x-litellm-call-id": call_id, "x-litellm-model-id": model_id, + "x-litellm-model-name": model_name, "x-litellm-cache-key": cache_key, "x-litellm-model-api-base": ( api_base.split("?")[0] if api_base else None @@ -1396,6 +1399,27 @@ class ProxyBaseLLMRequestProcessing: model_id = model_info.get("id", "") or "" return model_id + @staticmethod + def _get_deployment_model_name( + litellm_logging_obj: LiteLLMLoggingObj | None, + ) -> str | None: + """Extract the underlying deployment model string (e.g. ``azure/gpt-4o``). + + The router rewrites the response ``model`` field to the model-group alias + the client requested, so neither the response body nor the existing + headers expose the concrete deployment model. The router records it under + ``litellm_params`` metadata as ``deployment``, so read it back from there. + """ + litellm_params = getattr(litellm_logging_obj, "litellm_params", None) + if not isinstance(litellm_params, dict): + return None + for key in ("litellm_metadata", "metadata"): + metadata = litellm_params.get(key, {}) or {} + deployment = metadata.get("deployment") + if deployment: + return deployment + return None + @staticmethod def _response_cost_from_logging_obj( *, diff --git a/tests/test_litellm/proxy/test_model_id_header_propagation.py b/tests/test_litellm/proxy/test_model_id_header_propagation.py index e48168f89b3..f7f3eabae6d 100644 --- a/tests/test_litellm/proxy/test_model_id_header_propagation.py +++ b/tests/test_litellm/proxy/test_model_id_header_propagation.py @@ -200,6 +200,60 @@ def test_get_custom_headers_without_model_id(): assert headers["x-litellm-model-id"] in [None, ""] +class _FakeLoggingObj: + def __init__(self, litellm_params): + self.litellm_params = litellm_params + self.litellm_call_id = "test-call-id" + + +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +def test_get_custom_headers_includes_deployment_model_name(metadata_key): + """ + x-litellm-model-name should expose the underlying deployment model string, + which the router records under litellm_params[metadata]["deployment"]. + """ + mock_user_api_key_dict = MagicMock() + mock_user_api_key_dict.tpm_limit = 1000 + mock_user_api_key_dict.rpm_limit = 100 + + logging_obj = _FakeLoggingObj( + litellm_params={metadata_key: {"deployment": "azure/gpt-4o-2024-08-06"}} + ) + + headers = ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=mock_user_api_key_dict, + model_id="deployment-uuid", + request_data={}, + hidden_params={}, + litellm_logging_obj=logging_obj, + ) + + assert headers["x-litellm-model-name"] == "azure/gpt-4o-2024-08-06" + assert headers["x-litellm-model-id"] == "deployment-uuid" + + +def test_get_custom_headers_omits_model_name_when_deployment_missing(): + """ + Without a deployment model string, x-litellm-model-name must not be emitted + (rather than leaking an empty/None value). + """ + mock_user_api_key_dict = MagicMock() + mock_user_api_key_dict.tpm_limit = 1000 + mock_user_api_key_dict.rpm_limit = 100 + + logging_obj = _FakeLoggingObj(litellm_params={"metadata": {}}) + + headers = ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=mock_user_api_key_dict, + model_id="deployment-uuid", + request_data={}, + hidden_params={}, + litellm_logging_obj=logging_obj, + ) + + assert "x-litellm-model-name" not in headers + + def test_get_custom_headers_with_empty_string_model_id(): """ Test that get_custom_headers handles empty string model_id correctly.