diff --git a/litellm/main.py b/litellm/main.py index 38591441e01..c756287542f 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2678,7 +2678,10 @@ 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. 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 ff2c9012ec8..da98fb8be0f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -10751,10 +10751,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_main.py b/tests/unit/test_main.py index 63f440d4aba..1195d53ec9e 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -7680,34 +7680,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" 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