Skip to content

Commit 384ad7e

Browse files
committed
fix: avoid deepcopy crash with non-pickleables in Gemini/Vertex
1 parent 48d3aad commit 384ad7e

File tree

3 files changed

+69
-10
lines changed

3 files changed

+69
-10
lines changed

litellm/litellm_core_utils/core_helpers.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,11 @@ def safe_deep_copy(data):
228228
"""
229229
Safe Deep Copy
230230
231-
The LiteLLM Request has some object that can-not be pickled / deep copied
232-
233-
Use this function to safely deep copy the LiteLLM Request
231+
The LiteLLM request may contain objects that cannot be pickled/deep-copied
232+
(e.g., tracing spans, locks, clients).
233+
234+
This helper deep-copies each top-level key independently; on failure keeps
235+
original ref
234236
"""
235237
import copy
236238

@@ -255,9 +257,22 @@ def safe_deep_copy(data):
255257
"litellm_parent_otel_span"
256258
)
257259
data["litellm_metadata"]["litellm_parent_otel_span"] = "placeholder"
258-
new_data = copy.deepcopy(data)
259260

260-
# Step 2: re-add the litellm_parent_otel_span after doing a deep copy
261+
# Step 2: Per-key deepcopy with fallback
262+
if isinstance(data, dict):
263+
new_data = {}
264+
for k, v in data.items():
265+
try:
266+
new_data[k] = copy.deepcopy(v)
267+
except Exception:
268+
new_data[k] = v
269+
else:
270+
try:
271+
new_data = copy.deepcopy(data)
272+
except Exception:
273+
new_data = data
274+
275+
# Step 3: re-add the litellm_parent_otel_span after doing a deep copy
261276
if isinstance(data, dict) and litellm_parent_otel_span is not None:
262277
if "metadata" in data and "litellm_parent_otel_span" in data["metadata"]:
263278
data["metadata"]["litellm_parent_otel_span"] = litellm_parent_otel_span
@@ -268,4 +283,4 @@ def safe_deep_copy(data):
268283
data["litellm_metadata"][
269284
"litellm_parent_otel_span"
270285
] = litellm_parent_otel_span
271-
return new_data
286+
return new_data

litellm/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116

117117
from ._logging import verbose_logger
118118
from .caching.caching import disable_cache, enable_cache, update_cache
119+
from .litellm_core_utils.core_helpers import safe_deep_copy
119120
from .litellm_core_utils.fallback_utils import (
120121
async_completion_with_fallbacks,
121122
completion_with_fallbacks,
@@ -2772,8 +2773,7 @@ def completion( # type: ignore # noqa: PLR0915
27722773
)
27732774

27742775
api_base = api_base or litellm.api_base or get_secret("GEMINI_API_BASE")
2775-
2776-
new_params = deepcopy(optional_params)
2776+
new_params = safe_deep_copy(optional_params or {})
27772777
response = vertex_chat_completion.completion( # type: ignore
27782778
model=model,
27792779
messages=messages,
@@ -2817,7 +2817,7 @@ def completion( # type: ignore # noqa: PLR0915
28172817

28182818
api_base = api_base or litellm.api_base or get_secret("VERTEXAI_API_BASE")
28192819

2820-
new_params = deepcopy(optional_params)
2820+
new_params = safe_deep_copy(optional_params or {})
28212821
if vertex_partner_models_chat_completion.is_vertex_partner_model(model):
28222822
model_response = vertex_partner_models_chat_completion.completion(
28232823
model=model,

tests/test_litellm/litellm_core_utils/test_core_helpers.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
0, os.path.abspath("../../..")
1010
) # Adds the parent directory to the system path
1111

12-
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs, safe_divide
12+
from litellm.litellm_core_utils.core_helpers import (
13+
get_litellm_metadata_from_kwargs,
14+
safe_divide,
15+
safe_deep_copy
16+
)
1317

1418

1519
def test_get_litellm_metadata_from_kwargs():
@@ -127,3 +131,43 @@ def test_safe_divide_weight_scenario():
127131
expected_zero = [0, 0, 0]
128132

129133
assert normalized_zero_weights == expected_zero, f"Expected {expected_zero}, got {normalized_zero_weights}"
134+
135+
136+
def test_safe_deep_copy_with_non_pickleables_and_span():
137+
"""
138+
Verify safe_deep_copy:
139+
- does not crash when non-pickleables are present,
140+
- preserves structure/keys,
141+
- deep-copies JSON-y payloads (e.g., messages),
142+
- keeps non-pickleables by reference,
143+
- redacts OTEL span in the copy and restores it in the original.
144+
"""
145+
import threading
146+
rlock = threading.RLock()
147+
data = {
148+
"metadata": {"litellm_parent_otel_span": rlock, "x": 1},
149+
"messages": [{"role": "user", "content": "hi"}],
150+
"optional_params": {"handle": rlock},
151+
"ok": True,
152+
}
153+
154+
copied = safe_deep_copy(data)
155+
156+
# Structure preserved
157+
assert set(copied.keys()) == set(data.keys())
158+
159+
# Messages are deep-copied (new object, same content)
160+
assert copied["messages"] is not data["messages"]
161+
assert copied["messages"][0] == data["messages"][0]
162+
163+
# Non-pickleable subtree kept by reference (no crash)
164+
assert copied["optional_params"] is data["optional_params"]
165+
assert copied["optional_params"]["handle"] is rlock
166+
167+
# OTEL span: redacted in the copy, restored in original
168+
assert copied["metadata"]["litellm_parent_otel_span"] == "placeholder"
169+
assert data["metadata"]["litellm_parent_otel_span"] is rlock
170+
171+
# Other simple fields unchanged
172+
assert copied["ok"] is True
173+
assert copied["metadata"]["x"] == 1

0 commit comments

Comments
 (0)