1313
1414sys .path .insert (0 , os .path .abspath ("../.." ))
1515from litellm .integrations .langfuse .langfuse import LangFuseLogger
16+
1617# Import LangfuseUsageDetails directly from the module where it's defined
1718from 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+
230324def test_max_langfuse_clients_limit ():
231325 """
232326 Test that the max langfuse clients limit is respected when initializing multiple clients
0 commit comments