From 24e98c46423307ef609fa4d016345c8a93f23931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8E=E4=B8=96=E6=97=A0=E4=BA=89=E5=89=91=E5=AE=87?= =?UTF-8?q?=E5=AD=90?= <1530683628@qq.com> Date: Mon, 19 Oct 2026 12:37:09 +0800 Subject: [PATCH 1/3] fix(openai): forward request metadata without preview features --- litellm/main.py | 4 +++- tests/unit/test_main.py | 42 +++++++++++++++++++---------------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 38591441e01..64fa5e115d1 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2678,7 +2678,9 @@ def _complete_custom_openai( if extra_headers is not None and not use_base_llm_http_handler: optional_params["extra_headers"] = extra_headers - if litellm.enable_preview_features and metadata is not None: # [PREVIEW] allow metadata to be passed to OPENAI + # OpenAI accepts request metadata independently of preview features. Keep this + # limited to the OpenAI provider; compatible providers may reject the field. + if custom_llm_provider == "openai" and metadata is not None: openai_metadata: Final = get_requester_metadata(metadata) if openai_metadata is not None: optional_params["metadata"] = openai_metadata diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 7c292ea8f08..e919e0924dc 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -7633,34 +7633,30 @@ def test_completion_novita_ai_dynamic_params(api_key): "enable_preview_features", [True, False], ) -def test_completion_openai_metadata(monkeypatch, enable_preview_features): +def test_completion_openai_metadata( + monkeypatch, enable_preview_features, respx_mock: respx.MockRouter +): from openai import OpenAI client = OpenAI() - - litellm.set_verbose = True - monkeypatch.setattr(litellm, "enable_preview_features", enable_preview_features) - with patch.object( - client, "post", return_value=MagicMock() - ) as mock_completion: - try: - resp = litellm.completion( - model="openai/gpt-3.5-turbo", - messages=[{"role": "user", "content": "Hello world"}], - metadata={"my-test-key": "my-test-value"}, - client=client, - ) - except Exception as e: - print(f"Error: {e}") + respx_mock.post("https://api.openai.com/v1/chat/completions").mock( + return_value=_mocked_openai_chat_response("gpt-3.5-turbo") + ) - mock_completion.assert_called_once() - if enable_preview_features: - assert mock_completion.call_args.kwargs["body"]["metadata"] == { - "my-test-key": "my-test-value" - } - else: - assert "metadata" not in mock_completion.call_args.kwargs["body"] + litellm.completion( + model="openai/gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello world"}], + metadata={ + "session_id": "abc-123", + "hidden_params": {"api_key": "should-not-leak"}, + "non_string": 123, + }, + client=client, + ) + + request_body = json.loads(respx_mock.calls[0].request.content) + assert request_body["metadata"] == {"session_id": "abc-123"} AZURE_TTS_BASE: Final = "https://tts.example.azure.com" From 8d29f8738f6f608bffa9fe839f38ab69555dbfb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8E=E4=B8=96=E6=97=A0=E4=BA=89=E5=89=91=E5=AE=87?= =?UTF-8?q?=E5=AD=90?= <1530683628@qq.com> Date: Mon, 19 Oct 2026 13:12:56 +0800 Subject: [PATCH 2/3] fix: prevent auth metadata leakage and preserve preview forwarding --- litellm/main.py | 9 +++-- litellm/utils.py | 10 +++-- tests/unit/test_utils_get_optional_params.py | 42 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 64fa5e115d1..7fe3ac9775c 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2678,9 +2678,12 @@ def _complete_custom_openai( if extra_headers is not None and not use_base_llm_http_handler: optional_params["extra_headers"] = extra_headers - # OpenAI accepts request metadata independently of preview features. Keep this - # limited to the OpenAI provider; compatible providers may reject the field. - if custom_llm_provider == "openai" and metadata is not None: + # OpenAI accepts request metadata independently of preview features. Other + # providers keep the existing preview gate because compatible providers may + # reject the field. + if metadata is not None and ( + custom_llm_provider == "openai" or litellm.enable_preview_features + ): openai_metadata: Final = get_requester_metadata(metadata) if openai_metadata is not None: optional_params["metadata"] = openai_metadata diff --git a/litellm/utils.py b/litellm/utils.py index ab97b69897f..62c03fb4922 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -10749,10 +10749,12 @@ def get_requester_metadata(metadata: Mapping[str, object]): return None requester_metadata: Final = metadata.get("requester_metadata") - if isinstance(requester_metadata, dict): - cleaned_metadata = add_openai_metadata(requester_metadata) - if cleaned_metadata: - return cleaned_metadata + if "requester_metadata" in metadata: + # The proxy-populated requester snapshot is authoritative. Falling back + # to the outer dictionary would expose auth fields such as + # user_api_key_hash when the snapshot has no forwardable strings. + cleaned_metadata = add_openai_metadata(requester_metadata) if isinstance(requester_metadata, dict) else None + return cleaned_metadata if cleaned_metadata else None cleaned_metadata = add_openai_metadata(metadata) if cleaned_metadata: diff --git a/tests/unit/test_utils_get_optional_params.py b/tests/unit/test_utils_get_optional_params.py index 13a64577efb..e18c90fc627 100644 --- a/tests/unit/test_utils_get_optional_params.py +++ b/tests/unit/test_utils_get_optional_params.py @@ -57,6 +57,27 @@ def test_get_requester_metadata_returns_none_for_empty(): assert get_requester_metadata(metadata) is None +def test_get_requester_metadata_does_not_fall_back_to_sensitive_auth_metadata(): + metadata = { + "requester_metadata": { + "hidden_params": "secret", + "non_string": 123, + }, + "user_api_key": "sk-proxy-secret", + "user_api_key_hash": "opaque-custom-auth-token", + "user_api_key_auth": {"api_key": "sk-proxy-secret"}, + "session_id": "should-not-leak", + } + + assert get_requester_metadata(metadata) is None + + +def test_get_requester_metadata_uses_outer_metadata_without_requester_snapshot(): + assert get_requester_metadata({"session_id": "abc-123", "non_string": 123}) == { + "session_id": "abc-123" + } + + @patch("litellm.main.openai_chat_completions.completion") def test_requester_metadata_forwarded_to_openai(mock_completion): mock_completion.return_value = MagicMock() @@ -87,6 +108,27 @@ def test_requester_metadata_forwarded_to_openai(mock_completion): assert sent_metadata == {"custom_meta_key": "value"} +@patch("litellm.main.openai_chat_completions.completion") +def test_sensitive_auth_metadata_is_not_forwarded_with_empty_requester_snapshot( + mock_completion, monkeypatch +): + mock_completion.return_value = MagicMock() + monkeypatch.setattr(litellm, "api_key", "sk-test") + + litellm.completion( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + metadata={ + "requester_metadata": {"non_string": 123}, + "user_api_key": "sk-proxy-secret", + "user_api_key_hash": "opaque-custom-auth-token", + "session_id": "should-not-leak", + }, + ) + + assert "metadata" not in mock_completion.call_args.kwargs["optional_params"] + + def test_get_optional_params_with_allowed_openai_params(): """ Test if use can dynamically pass in allowed_openai_params to override default behavior From be110a0b68ac7fb321094d6e7eeb95211370b4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8E=E4=B8=96=E6=97=A0=E4=BA=89=E5=89=91=E5=AE=87?= =?UTF-8?q?=E5=AD=90?= <1530683628@qq.com> Date: Mon, 19 Oct 2026 13:28:06 +0800 Subject: [PATCH 3/3] style: format metadata forwarding condition --- litellm/main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 7fe3ac9775c..c756287542f 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2681,9 +2681,7 @@ def _complete_custom_openai( # OpenAI accepts request metadata independently of preview features. Other # providers keep the existing preview gate because compatible providers may # reject the field. - if metadata is not None and ( - custom_llm_provider == "openai" or litellm.enable_preview_features - ): + if metadata is not None and (custom_llm_provider == "openai" or litellm.enable_preview_features): openai_metadata: Final = get_requester_metadata(metadata) if openai_metadata is not None: optional_params["metadata"] = openai_metadata