diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index 154306d01b8..f2973cecb99 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -34,7 +34,9 @@ def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str: result: Union[dict, list, tuple, set, str] if isinstance(obj, dict): result = {} - for k, v in obj.items(): + # Snapshot items() so a concurrent async hook mutating the same + # dict can't raise "dictionary changed size during iteration". + for k, v in list(obj.items()): if isinstance(k, (str)): result[strip_null_bytes(k)] = _serialize(v, seen, depth + 1) seen.remove(id(obj)) diff --git a/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py b/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py index 74574370e46..d2ec2f2e7e2 100644 --- a/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py +++ b/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py @@ -206,3 +206,26 @@ def test_pydantic_base_model(): assert len(result["healthy_endpoints"]) == 2 assert result["healthy_endpoints"][0]["name"] == "test" assert result["healthy_endpoints"][1] == {"value": 1, "label": "one"} + + +def test_dict_mutated_during_serialization_does_not_raise(): + # Regression: safe_dumps runs on request_data / model_call_details, which a + # concurrent async hook can mutate while we iterate. Serializing one value + # here inserts a new key into the same dict (standing in for that hook). On + # the pre-fix code this raised "RuntimeError: dictionary changed size during + # iteration"; the list() snapshot must keep iteration stable. + class _MutateOnStr: + def __init__(self, target): + self._target = target + + def __str__(self): + self._target["injected_by_concurrent_hook"] = "value" + return "mutated" + + data = {} + data["trigger"] = _MutateOnStr(data) + data["after"] = 1 + + result = json.loads(safe_dumps(data)) + assert result["trigger"] == "mutated" + assert result["after"] == 1