Skip to content

Commit 5f12e4b

Browse files
fix(langfuse): Handle null usage values to prevent validation errors (#16396)
* langfuse null validation fix * formatting
1 parent 7b292cc commit 5f12e4b

File tree

2 files changed

+137
-33
lines changed

2 files changed

+137
-33
lines changed

litellm/integrations/langfuse/langfuse.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -683,23 +683,33 @@ def _log_langfuse_v2( # noqa: PLR0915
683683
_usage_obj = getattr(response_obj, "usage", None)
684684

685685
if _usage_obj:
686+
# Safely get usage values, defaulting None to 0 for Langfuse compatibility.
687+
# Some providers may return null for token counts.
688+
prompt_tokens = getattr(_usage_obj, "prompt_tokens", None) or 0
689+
completion_tokens = (
690+
getattr(_usage_obj, "completion_tokens", None) or 0
691+
)
692+
total_tokens = getattr(_usage_obj, "total_tokens", None) or 0
693+
694+
cache_creation_input_tokens = (
695+
_usage_obj.get("cache_creation_input_tokens") or 0
696+
)
697+
cache_read_input_tokens = (
698+
_usage_obj.get("cache_read_input_tokens") or 0
699+
)
700+
686701
usage = {
687-
"prompt_tokens": _usage_obj.prompt_tokens,
688-
"completion_tokens": _usage_obj.completion_tokens,
702+
"prompt_tokens": prompt_tokens,
703+
"completion_tokens": completion_tokens,
689704
"total_cost": cost if self._supports_costs() else None,
690705
}
691-
cache_read_input_tokens = _usage_obj.get(
692-
"cache_read_input_tokens", 0
693-
)
694706
# According to langfuse documentation: "the input value must be reduced by the number of cache_read_input_tokens"
695-
input_tokens = _usage_obj.prompt_tokens - cache_read_input_tokens
707+
input_tokens = prompt_tokens - cache_read_input_tokens
696708
usage_details = LangfuseUsageDetails(
697709
input=input_tokens,
698-
output=_usage_obj.completion_tokens,
699-
total=_usage_obj.total_tokens,
700-
cache_creation_input_tokens=_usage_obj.get(
701-
"cache_creation_input_tokens", 0
702-
),
710+
output=completion_tokens,
711+
total=total_tokens,
712+
cache_creation_input_tokens=cache_creation_input_tokens,
703713
cache_read_input_tokens=cache_read_input_tokens,
704714
)
705715

tests/test_litellm/integrations/test_langfuse.py

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,22 @@
1313

1414
sys.path.insert(0, os.path.abspath("../.."))
1515
from litellm.integrations.langfuse.langfuse import LangFuseLogger
16+
1617
# Import LangfuseUsageDetails directly from the module where it's defined
1718
from litellm.types.integrations.langfuse import *
1819

19-
class TestLangfuseUsageDetails(unittest.TestCase):
2020

21+
class TestLangfuseUsageDetails(unittest.TestCase):
2122
def setUp(self):
2223
# Set up environment variables for testing
23-
self.env_patcher = patch.dict('os.environ', {
24-
'LANGFUSE_SECRET_KEY': 'test-secret-key',
25-
'LANGFUSE_PUBLIC_KEY': 'test-public-key',
26-
'LANGFUSE_HOST': 'https://test.langfuse.com'
27-
})
24+
self.env_patcher = patch.dict(
25+
"os.environ",
26+
{
27+
"LANGFUSE_SECRET_KEY": "test-secret-key",
28+
"LANGFUSE_PUBLIC_KEY": "test-public-key",
29+
"LANGFUSE_HOST": "https://test.langfuse.com",
30+
},
31+
)
2832
self.env_patcher.start()
2933

3034
# Create mock objects
@@ -37,21 +41,25 @@ def setUp(self):
3741
self.mock_langfuse_client.trace.return_value = self.mock_langfuse_trace
3842

3943
# Mock the langfuse module that's imported locally in methods
40-
self.langfuse_module_patcher = patch.dict('sys.modules', {'langfuse': MagicMock()})
44+
self.langfuse_module_patcher = patch.dict(
45+
"sys.modules", {"langfuse": MagicMock()}
46+
)
4147
self.mock_langfuse_module = self.langfuse_module_patcher.start()
4248

4349
# Create a mock for the langfuse module with version
4450
self.mock_langfuse = MagicMock()
4551
self.mock_langfuse.version = MagicMock()
46-
self.mock_langfuse.version.__version__ = "3.0.0" # Set a version that supports all features
52+
self.mock_langfuse.version.__version__ = (
53+
"3.0.0" # Set a version that supports all features
54+
)
4755

4856
# Mock the Langfuse class
4957
self.mock_langfuse_class = MagicMock()
5058
self.mock_langfuse_class.return_value = self.mock_langfuse_client
5159

5260
# Set up the sys.modules['langfuse'] mock
53-
sys.modules['langfuse'] = self.mock_langfuse
54-
sys.modules['langfuse'].Langfuse = self.mock_langfuse_class
61+
sys.modules["langfuse"] = self.mock_langfuse
62+
sys.modules["langfuse"].Langfuse = self.mock_langfuse_class
5563

5664
# Mock the Langfuse client
5765
self.mock_langfuse_client = MagicMock()
@@ -71,7 +79,16 @@ def setUp(self):
7179
self.logger = LangFuseLogger()
7280

7381
# Add the log_event_on_langfuse method to the instance
74-
def log_event_on_langfuse(self, kwargs, response_obj, start_time=None, end_time=None, user_id=None, level="DEFAULT", status_message=None):
82+
def log_event_on_langfuse(
83+
self,
84+
kwargs,
85+
response_obj,
86+
start_time=None,
87+
end_time=None,
88+
user_id=None,
89+
level="DEFAULT",
90+
status_message=None,
91+
):
7592
# This implementation calls _log_langfuse_v2 directly
7693
return self._log_langfuse_v2(
7794
user_id=user_id,
@@ -86,12 +103,15 @@ def log_event_on_langfuse(self, kwargs, response_obj, start_time=None, end_time=
86103
response_obj=response_obj,
87104
level=level,
88105
litellm_call_id=kwargs.get("litellm_call_id", None),
89-
print_verbose=True # Add the missing parameter
106+
print_verbose=True, # Add the missing parameter
90107
)
91108

92109
# Bind the method to the instance
93110
import types
94-
self.logger.log_event_on_langfuse = types.MethodType(log_event_on_langfuse, self.logger)
111+
112+
self.logger.log_event_on_langfuse = types.MethodType(
113+
log_event_on_langfuse, self.logger
114+
)
95115

96116
# Make sure _is_langfuse_v2 returns True
97117
def mock_is_langfuse_v2(self):
@@ -111,7 +131,7 @@ def test_langfuse_usage_details_type(self):
111131
"output": 20,
112132
"total": 30,
113133
"cache_creation_input_tokens": 5,
114-
"cache_read_input_tokens": 3
134+
"cache_read_input_tokens": 3,
115135
}
116136

117137
# Verify all fields are present
@@ -127,7 +147,7 @@ def test_langfuse_usage_details_type(self):
127147
"output": 20,
128148
"total": 30,
129149
"cache_creation_input_tokens": 0,
130-
"cache_read_input_tokens": 0
150+
"cache_read_input_tokens": 0,
131151
}
132152

133153
self.assertEqual(minimal_usage_details["input"], 10)
@@ -144,9 +164,9 @@ def test_log_langfuse_v2_usage_details(self):
144164

145165
# Add the cache token attributes using get method
146166
def mock_get(key, default=None):
147-
if key == 'cache_creation_input_tokens':
167+
if key == "cache_creation_input_tokens":
148168
return 7
149-
elif key == 'cache_read_input_tokens':
169+
elif key == "cache_read_input_tokens":
150170
return 4
151171
return default
152172

@@ -156,20 +176,20 @@ def mock_get(key, default=None):
156176
kwargs = {
157177
"model": "gpt-4",
158178
"messages": [{"role": "user", "content": "Hello"}],
159-
"litellm_params": {"metadata": {}}
179+
"litellm_params": {"metadata": {}},
160180
}
161181

162182
# Create start and end times
163183
start_time = datetime.datetime.now()
164184
end_time = start_time + datetime.timedelta(seconds=1)
165185

166186
# Call the log_event method
167-
with patch.object(self.logger, '_log_langfuse_v2') as mock_log_langfuse_v2:
187+
with patch.object(self.logger, "_log_langfuse_v2") as mock_log_langfuse_v2:
168188
self.logger.log_event_on_langfuse(
169189
kwargs=kwargs,
170190
response_obj=response_obj,
171191
start_time=start_time,
172-
end_time=end_time
192+
end_time=end_time,
173193
)
174194

175195
# Check if _log_langfuse_v2 was called
@@ -189,7 +209,7 @@ def test_langfuse_usage_details_optional_fields(self):
189209
"output": 20,
190210
"total": 30,
191211
"cache_creation_input_tokens": None,
192-
"cache_read_input_tokens": None
212+
"cache_read_input_tokens": None,
193213
}
194214

195215
# Verify fields can be None
@@ -210,7 +230,7 @@ def test_langfuse_usage_details_structure(self):
210230
"output": 25,
211231
"total": 40,
212232
"cache_creation_input_tokens": 7,
213-
"cache_read_input_tokens": 4
233+
"cache_read_input_tokens": 4,
214234
}
215235

216236
# Verify the structure matches what we expect
@@ -227,6 +247,80 @@ def test_langfuse_usage_details_structure(self):
227247
self.assertEqual(usage_details["cache_creation_input_tokens"], 7)
228248
self.assertEqual(usage_details["cache_read_input_tokens"], 4)
229249

250+
def test_log_langfuse_v2_handles_null_usage_values(self):
251+
"""
252+
Test that _log_langfuse_v2 correctly handles None values in the usage object
253+
by converting them to 0, preventing validation errors.
254+
"""
255+
with patch(
256+
"litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params",
257+
side_effect=lambda generation_params, **kwargs: generation_params,
258+
) as mock_add_prompt_params:
259+
# Create a mock response object with usage information containing None values
260+
response_obj = MagicMock()
261+
response_obj.usage = MagicMock()
262+
response_obj.usage.prompt_tokens = None
263+
response_obj.usage.completion_tokens = None
264+
response_obj.usage.total_tokens = None
265+
266+
# Mock the .get() method to return None for cache-related fields
267+
def mock_get(key, default=None):
268+
if key in ["cache_creation_input_tokens", "cache_read_input_tokens"]:
269+
return None
270+
return default
271+
272+
response_obj.usage.get = mock_get
273+
274+
# Prepare standard kwargs for the call
275+
kwargs = {
276+
"model": "gpt-4-null-usage",
277+
"messages": [{"role": "user", "content": "Test"}],
278+
"litellm_params": {"metadata": {}},
279+
"optional_params": {},
280+
"litellm_call_id": "test-call-id-null-usage",
281+
"standard_logging_object": None,
282+
"response_cost": 0.0,
283+
}
284+
285+
# Call the method under test
286+
self.logger._log_langfuse_v2(
287+
user_id="test-user",
288+
metadata={},
289+
litellm_params=kwargs["litellm_params"],
290+
output={"role": "assistant", "content": "Response"},
291+
start_time=datetime.datetime.now(),
292+
end_time=datetime.datetime.now(),
293+
kwargs=kwargs,
294+
optional_params=kwargs["optional_params"],
295+
input={"messages": kwargs["messages"]},
296+
response_obj=response_obj,
297+
level="DEFAULT",
298+
litellm_call_id=kwargs["litellm_call_id"],
299+
)
300+
# Check the arguments passed to the mocked langfuse generation call
301+
self.mock_langfuse_trace.generation.assert_called_once()
302+
call_args, call_kwargs = self.mock_langfuse_trace.generation.call_args
303+
304+
# Inspect the usage and usage_details dictionaries
305+
usage_arg = call_kwargs.get("usage")
306+
usage_details_arg = call_kwargs.get("usage_details")
307+
308+
self.assertIsNotNone(usage_arg)
309+
self.assertIsNotNone(usage_details_arg)
310+
311+
# Verify that None values were converted to 0
312+
self.assertEqual(usage_arg["prompt_tokens"], 0)
313+
self.assertEqual(usage_arg["completion_tokens"], 0)
314+
315+
self.assertEqual(usage_details_arg["input"], 0)
316+
self.assertEqual(usage_details_arg["output"], 0)
317+
self.assertEqual(usage_details_arg["total"], 0)
318+
self.assertEqual(usage_details_arg["cache_creation_input_tokens"], 0)
319+
self.assertEqual(usage_details_arg["cache_read_input_tokens"], 0)
320+
321+
mock_add_prompt_params.assert_called_once()
322+
323+
230324
def test_max_langfuse_clients_limit():
231325
"""
232326
Test that the max langfuse clients limit is respected when initializing multiple clients

0 commit comments

Comments
 (0)