From 0d7681acd16632fd8a614b2cb56bd390b1b23b70 Mon Sep 17 00:00:00 2001 From: Lucain Pouget Date: Tue, 24 Jun 2025 20:02:13 +0200 Subject: [PATCH 1/5] Recursive filter_none in Inference Providers --- .../inference/_providers/_common.py | 6 ++++- .../inference/_providers/hf_inference.py | 2 +- tests/test_inference_providers.py | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/huggingface_hub/inference/_providers/_common.py b/src/huggingface_hub/inference/_providers/_common.py index 97b774a4f2..d513ab9140 100644 --- a/src/huggingface_hub/inference/_providers/_common.py +++ b/src/huggingface_hub/inference/_providers/_common.py @@ -37,7 +37,11 @@ def filter_none(d: Dict[str, Any]) -> Dict[str, Any]: - return {k: v for k, v in d.items() if v is not None} + return { + k: v_filtered + for k, v in d.items() + if (v_filtered := filter_none(v) if isinstance(v, dict) else v) is not None and v_filtered != {} + } class TaskProviderHelper: diff --git a/src/huggingface_hub/inference/_providers/hf_inference.py b/src/huggingface_hub/inference/_providers/hf_inference.py index c5549e9b3d..f5531a02c7 100644 --- a/src/huggingface_hub/inference/_providers/hf_inference.py +++ b/src/huggingface_hub/inference/_providers/hf_inference.py @@ -75,7 +75,7 @@ def _prepare_payload_as_bytes( provider_mapping_info: InferenceProviderMapping, extra_payload: Optional[Dict], ) -> Optional[bytes]: - parameters = filter_none({k: v for k, v in parameters.items() if v is not None}) + parameters = filter_none(parameters) extra_payload = extra_payload or {} has_parameters = len(parameters) > 0 or len(extra_payload) > 0 diff --git a/tests/test_inference_providers.py b/tests/test_inference_providers.py index 33fdb3a921..7fad3d5ff6 100644 --- a/tests/test_inference_providers.py +++ b/tests/test_inference_providers.py @@ -13,6 +13,7 @@ BaseConversationalTask, BaseTextGenerationTask, TaskProviderHelper, + filter_none, recursive_merge, ) from huggingface_hub.inference._providers.black_forest_labs import BlackForestLabsTextToImageTask @@ -1236,6 +1237,28 @@ def test_recursive_merge(dict1: Dict, dict2: Dict, expected: Dict): assert dict2 == initial_dict2 +@pytest.mark.parametrize( + "data, expected", + [ + ({}, {}), # empty dictionary remains empty + ({"a": 1, "b": None, "c": 3}, {"a": 1, "c": 3}), # remove None at root level + ({"a": None, "b": {"x": None, "y": 2}}, {"b": {"y": 2}}), # remove nested None + ({"a": {"b": {"c": None}}}, {}), # remove empty nested dict + ( + {"a": "", "b": {"x": {"y": None}, "z": 0}, "c": []}, # do not remove 0, [] and "" values + {"a": "", "b": {"z": 0}, "c": []}, + ), + ( + {"a": [0, 1, None]}, # do not remove None in lists + {"a": [0, 1, None]}, + ), + ], +) +def test_filter_none(data: Dict, expected: Dict): + """Test that filter_none removes None values from nested dictionaries.""" + assert filter_none(data) == expected + + def test_get_provider_helper_auto(mocker): """Test the 'auto' provider selection logic.""" From f3f7f2998b74109756c2328d6369af8dc5bf4661 Mon Sep 17 00:00:00 2001 From: Celina Hanouti Date: Wed, 25 Jun 2025 10:17:41 +0200 Subject: [PATCH 2/5] filter none values from messages as well --- src/huggingface_hub/inference/_providers/_common.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/huggingface_hub/inference/_providers/_common.py b/src/huggingface_hub/inference/_providers/_common.py index d513ab9140..13054e6198 100644 --- a/src/huggingface_hub/inference/_providers/_common.py +++ b/src/huggingface_hub/inference/_providers/_common.py @@ -4,6 +4,7 @@ from huggingface_hub import constants from huggingface_hub.hf_api import InferenceProviderMapping from huggingface_hub.inference._common import RequestParameters +from huggingface_hub.inference._generated.types.chat_completion import ChatCompletionInputMessage from huggingface_hub.utils import build_hf_headers, get_token, logging @@ -228,8 +229,12 @@ def _prepare_route(self, mapped_model: str, api_key: str) -> str: return "/v1/chat/completions" def _prepare_payload_as_dict( - self, inputs: Any, parameters: Dict, provider_mapping_info: InferenceProviderMapping + self, + inputs: List[Union[Dict, ChatCompletionInputMessage]], + parameters: Dict, + provider_mapping_info: InferenceProviderMapping, ) -> Optional[Dict]: + inputs = [filter_none(message) for message in inputs] return {"messages": inputs, **filter_none(parameters), "model": provider_mapping_info.provider_id} From 9fb612ef36b06eff8947e5fbfc0cce4a89c46172 Mon Sep 17 00:00:00 2001 From: Celina Hanouti Date: Wed, 25 Jun 2025 10:56:00 +0200 Subject: [PATCH 3/5] update filter_none and add test cases --- .../inference/_providers/_common.py | 35 ++++-- tests/test_inference_providers.py | 100 ++++++++++++++++++ 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/src/huggingface_hub/inference/_providers/_common.py b/src/huggingface_hub/inference/_providers/_common.py index 13054e6198..121cc7bafd 100644 --- a/src/huggingface_hub/inference/_providers/_common.py +++ b/src/huggingface_hub/inference/_providers/_common.py @@ -37,12 +37,32 @@ } -def filter_none(d: Dict[str, Any]) -> Dict[str, Any]: - return { - k: v_filtered - for k, v in d.items() - if (v_filtered := filter_none(v) if isinstance(v, dict) else v) is not None and v_filtered != {} - } +def filter_none(obj: Union[Dict[str, Any], List[Any]]) -> Dict[str, Any]: + if isinstance(obj, dict): + cleaned: Dict[str, Any] = {} + for k, v in obj.items(): + if v is None: + continue + if isinstance(v, (dict, list)): + v = filter_none(v) + # remove empty nested dicts + if isinstance(v, dict) and not v: + continue + cleaned[k] = v + return cleaned + + if isinstance(obj, list): + cleaned_list: List[Any] = [] + for v in obj: + if isinstance(v, (dict, list)): + v = filter_none(v) + if isinstance(v, dict) and not v: + continue + + cleaned_list.append(v) + return cleaned_list # type: ignore [return-value] + + raise ValueError(f"Expected dict or list, got {type(obj)}") class TaskProviderHelper: @@ -234,8 +254,7 @@ def _prepare_payload_as_dict( parameters: Dict, provider_mapping_info: InferenceProviderMapping, ) -> Optional[Dict]: - inputs = [filter_none(message) for message in inputs] - return {"messages": inputs, **filter_none(parameters), "model": provider_mapping_info.provider_id} + return filter_none({"messages": inputs, **parameters, "model": provider_mapping_info.provider_id}) class BaseTextGenerationTask(TaskProviderHelper): diff --git a/tests/test_inference_providers.py b/tests/test_inference_providers.py index 7fad3d5ff6..785ea9aad5 100644 --- a/tests/test_inference_providers.py +++ b/tests/test_inference_providers.py @@ -1153,6 +1153,98 @@ def test_prepare_payload(self): "model": "test-provider-id", } + @pytest.mark.parametrize( + "raw_messages, expected_messages", + [ + ( + [ + { + "role": "assistant", + "content": "", + "tool_calls": None, + } + ], + [ + { + "role": "assistant", + "content": "", + } + ], + ), + ( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_current_weather", + "arguments": '{"location": "San Francisco, CA", "unit": "celsius"}', + }, + }, + ], + }, + { + "role": "tool", + "content": "pong", + "tool_call_id": "abc123", + "name": "dummy_tool", + "tool_calls": None, + }, + ], + [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_current_weather", + "arguments": '{"location": "San Francisco, CA", "unit": "celsius"}', + }, + } + ], + }, + { + "role": "tool", + "content": "pong", + "tool_call_id": "abc123", + "name": "dummy_tool", + }, + ], + ), + ], + ) + def test_prepare_payload_filters_messages(self, raw_messages, expected_messages): + helper = BaseConversationalTask(provider="test-provider", base_url="https://api.test.com") + + parameters = { + "temperature": 0.2, + "max_tokens": None, + "top_p": None, + } + + payload = helper._prepare_payload_as_dict( + inputs=raw_messages, + parameters=parameters, + provider_mapping_info=InferenceProviderMapping( + provider="test-provider", + hf_model_id="test-model", + providerId="test-provider-id", + task="conversational", + status="live", + ), + ) + + assert payload["messages"] == expected_messages + assert payload["temperature"] == 0.2 + assert "max_tokens" not in payload + assert "top_p" not in payload + class TestBaseTextGenerationTask: def test_prepare_route(self): @@ -1252,6 +1344,14 @@ def test_recursive_merge(dict1: Dict, dict2: Dict, expected: Dict): {"a": [0, 1, None]}, # do not remove None in lists {"a": [0, 1, None]}, ), + # dicts inside list are cleaned, list level None kept + ({"a": [{"x": None, "y": 1}, None]}, {"a": [{"y": 1}, None]}), + # remove every None that is the value of a dict key + ( + [None, {"x": None, "y": 5}, [None, 6]], + [None, {"y": 5}, [None, 6]], + ), + ({"a": [None, {"x": None}]}, {"a": [None]}), ], ) def test_filter_none(data: Dict, expected: Dict): From 03019ff38730d64e92e8e7447c5f4f5648cb6d2f Mon Sep 17 00:00:00 2001 From: Celina Hanouti Date: Wed, 25 Jun 2025 11:39:00 +0200 Subject: [PATCH 4/5] don't drop empty dicts in list --- src/huggingface_hub/inference/_providers/_common.py | 2 -- tests/test_inference_providers.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/huggingface_hub/inference/_providers/_common.py b/src/huggingface_hub/inference/_providers/_common.py index 121cc7bafd..6e82587ce0 100644 --- a/src/huggingface_hub/inference/_providers/_common.py +++ b/src/huggingface_hub/inference/_providers/_common.py @@ -56,8 +56,6 @@ def filter_none(obj: Union[Dict[str, Any], List[Any]]) -> Dict[str, Any]: for v in obj: if isinstance(v, (dict, list)): v = filter_none(v) - if isinstance(v, dict) and not v: - continue cleaned_list.append(v) return cleaned_list # type: ignore [return-value] diff --git a/tests/test_inference_providers.py b/tests/test_inference_providers.py index 785ea9aad5..3b3a8f671c 100644 --- a/tests/test_inference_providers.py +++ b/tests/test_inference_providers.py @@ -1351,7 +1351,7 @@ def test_recursive_merge(dict1: Dict, dict2: Dict, expected: Dict): [None, {"x": None, "y": 5}, [None, 6]], [None, {"y": 5}, [None, 6]], ), - ({"a": [None, {"x": None}]}, {"a": [None]}), + ({"a": [None, {"x": None}]}, {"a": [None, {}]}), ], ) def test_filter_none(data: Dict, expected: Dict): From aba422721a5795fe1a9917f50411a9794d9ab699 Mon Sep 17 00:00:00 2001 From: Celina Hanouti Date: Wed, 25 Jun 2025 11:57:51 +0200 Subject: [PATCH 5/5] better typing and refactor logic into a list of comrpehension --- .../inference/_providers/_common.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/huggingface_hub/inference/_providers/_common.py b/src/huggingface_hub/inference/_providers/_common.py index 6e82587ce0..8ce62d56ea 100644 --- a/src/huggingface_hub/inference/_providers/_common.py +++ b/src/huggingface_hub/inference/_providers/_common.py @@ -1,5 +1,5 @@ from functools import lru_cache -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, overload from huggingface_hub import constants from huggingface_hub.hf_api import InferenceProviderMapping @@ -37,7 +37,13 @@ } -def filter_none(obj: Union[Dict[str, Any], List[Any]]) -> Dict[str, Any]: +@overload +def filter_none(obj: Dict[str, Any]) -> Dict[str, Any]: ... +@overload +def filter_none(obj: List[Any]) -> List[Any]: ... + + +def filter_none(obj: Union[Dict[str, Any], List[Any]]) -> Union[Dict[str, Any], List[Any]]: if isinstance(obj, dict): cleaned: Dict[str, Any] = {} for k, v in obj.items(): @@ -52,13 +58,7 @@ def filter_none(obj: Union[Dict[str, Any], List[Any]]) -> Dict[str, Any]: return cleaned if isinstance(obj, list): - cleaned_list: List[Any] = [] - for v in obj: - if isinstance(v, (dict, list)): - v = filter_none(v) - - cleaned_list.append(v) - return cleaned_list # type: ignore [return-value] + return [filter_none(v) if isinstance(v, (dict, list)) else v for v in obj] raise ValueError(f"Expected dict or list, got {type(obj)}")