diff --git a/src/llamafactory/chat/hf_engine.py b/src/llamafactory/chat/hf_engine.py index 1e670b92c9..478cd6a0f0 100644 --- a/src/llamafactory/chat/hf_engine.py +++ b/src/llamafactory/chat/hf_engine.py @@ -82,8 +82,9 @@ def _process_args( images: Optional[list["ImageInput"]] = None, videos: Optional[list["VideoInput"]] = None, audios: Optional[list["AudioInput"]] = None, - input_kwargs: Optional[dict[str, Any]] = {}, + input_kwargs: Optional[dict[str, Any]] = None, ) -> tuple[dict[str, Any], int]: + input_kwargs = {} if input_kwargs is None else dict(input_kwargs) mm_input_dict = {"images": [], "videos": [], "audios": [], "imglens": [0], "vidlens": [0], "audlens": [0]} if images is not None: mm_input_dict.update({"images": images, "imglens": [len(images)]}) @@ -221,8 +222,9 @@ def _chat( images: Optional[list["ImageInput"]] = None, videos: Optional[list["VideoInput"]] = None, audios: Optional[list["AudioInput"]] = None, - input_kwargs: Optional[dict[str, Any]] = {}, + input_kwargs: Optional[dict[str, Any]] = None, ) -> list["Response"]: + input_kwargs = {} if input_kwargs is None else dict(input_kwargs) gen_kwargs, prompt_length = HuggingfaceEngine._process_args( model, tokenizer, @@ -276,8 +278,9 @@ def _stream_chat( images: Optional[list["ImageInput"]] = None, videos: Optional[list["VideoInput"]] = None, audios: Optional[list["AudioInput"]] = None, - input_kwargs: Optional[dict[str, Any]] = {}, + input_kwargs: Optional[dict[str, Any]] = None, ) -> Callable[[], str]: + input_kwargs = {} if input_kwargs is None else dict(input_kwargs) gen_kwargs, _ = HuggingfaceEngine._process_args( model, tokenizer, @@ -315,8 +318,9 @@ def _get_scores( model: "PreTrainedModelWrapper", tokenizer: "PreTrainedTokenizer", batch_input: list[str], - input_kwargs: Optional[dict[str, Any]] = {}, + input_kwargs: Optional[dict[str, Any]] = None, ) -> list[float]: + input_kwargs = {} if input_kwargs is None else dict(input_kwargs) max_length: Optional[int] = input_kwargs.pop("max_length", None) device = getattr(model.pretrained_model, "device", "cuda") inputs: dict[str, torch.Tensor] = tokenizer( diff --git a/tests/chat/test_hf_engine.py b/tests/chat/test_hf_engine.py new file mode 100644 index 0000000000..2bcd0978bf --- /dev/null +++ b/tests/chat/test_hf_engine.py @@ -0,0 +1,69 @@ +# Copyright 2025 the LlamaFactory team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ast +from pathlib import Path + + +def test_huggingface_engine_input_kwargs_defaults_are_not_mutable(): + module_path = Path(__file__).parents[2] / "src" / "llamafactory" / "chat" / "hf_engine.py" + module = ast.parse(module_path.read_text(encoding="utf-8")) + + expected_methods = {"_process_args", "_chat", "_stream_chat", "_get_scores"} + methods = { + node.name: node + for node in ast.walk(module) + if isinstance(node, ast.FunctionDef) and node.name in expected_methods + } + + assert set(methods) == expected_methods + + for method in methods.values(): + arguments = method.args.args + defaults = method.args.defaults + default_by_arg = dict(zip((arg.arg for arg in arguments[-len(defaults) :]), defaults)) + + default = default_by_arg["input_kwargs"] + assert isinstance(default, ast.Constant) + assert default.value is None + + +def test_huggingface_engine_input_kwargs_are_copied_before_use(): + module_path = Path(__file__).parents[2] / "src" / "llamafactory" / "chat" / "hf_engine.py" + module = ast.parse(module_path.read_text(encoding="utf-8")) + + expected_methods = {"_process_args", "_chat", "_stream_chat", "_get_scores"} + methods = { + node.name: node + for node in ast.walk(module) + if isinstance(node, ast.FunctionDef) and node.name in expected_methods + } + + for method in methods.values(): + first_statement = method.body[0] + assert isinstance(first_statement, ast.Assign) + assert len(first_statement.targets) == 1 + assert isinstance(first_statement.targets[0], ast.Name) + assert first_statement.targets[0].id == "input_kwargs" + + value = first_statement.value + assert isinstance(value, ast.IfExp) + assert isinstance(value.test, ast.Compare) + assert isinstance(value.body, ast.Dict) + assert isinstance(value.orelse, ast.Call) + assert isinstance(value.orelse.func, ast.Name) + assert value.orelse.func.id == "dict" + assert len(value.orelse.args) == 1 + assert isinstance(value.orelse.args[0], ast.Name) + assert value.orelse.args[0].id == "input_kwargs"