revenium-python-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. revenium_middleware/__init__.py +184 -0
  2. revenium_middleware/_core/__init__.py +65 -0
  3. revenium_middleware/_core/config.py +165 -0
  4. revenium_middleware/_core/context.py +109 -0
  5. revenium_middleware/_core/decorators.py +202 -0
  6. revenium_middleware/_core/metering.py +207 -0
  7. revenium_middleware/_core/prompt_extraction.py +55 -0
  8. revenium_middleware/_core/subscriber.py +51 -0
  9. revenium_middleware/_core/trace_fields.py +265 -0
  10. revenium_middleware/anthropic/__init__.py +108 -0
  11. revenium_middleware/anthropic/bedrock_adapter.py +753 -0
  12. revenium_middleware/anthropic/config.py +29 -0
  13. revenium_middleware/anthropic/middleware.py +1070 -0
  14. revenium_middleware/anthropic/prompt_extractor.py +178 -0
  15. revenium_middleware/anthropic/provider.py +141 -0
  16. revenium_middleware/anthropic/summary_printer.py +286 -0
  17. revenium_middleware/anthropic/trace_fields.py +158 -0
  18. revenium_middleware/google/__init__.py +114 -0
  19. revenium_middleware/google/common/__init__.py +127 -0
  20. revenium_middleware/google/common/exceptions.py +137 -0
  21. revenium_middleware/google/common/protocols.py +192 -0
  22. revenium_middleware/google/common/summary_printer.py +271 -0
  23. revenium_middleware/google/common/trace_fields.py +205 -0
  24. revenium_middleware/google/common/types.py +208 -0
  25. revenium_middleware/google/common/utils.py +1111 -0
  26. revenium_middleware/google/config.py +64 -0
  27. revenium_middleware/google/google_ai/__init__.py +53 -0
  28. revenium_middleware/google/google_ai/middleware.py +667 -0
  29. revenium_middleware/google/google_ai/provider.py +135 -0
  30. revenium_middleware/google/prompt_extractor.py +396 -0
  31. revenium_middleware/google/vertex_ai/__init__.py +56 -0
  32. revenium_middleware/google/vertex_ai/middleware.py +1162 -0
  33. revenium_middleware/google/vertex_ai/provider.py +99 -0
  34. revenium_middleware/litellm/__init__.py +25 -0
  35. revenium_middleware/litellm/client/__init__.py +81 -0
  36. revenium_middleware/litellm/client/config.py +53 -0
  37. revenium_middleware/litellm/client/context.py +198 -0
  38. revenium_middleware/litellm/client/decorators.py +912 -0
  39. revenium_middleware/litellm/client/hooks.py +192 -0
  40. revenium_middleware/litellm/client/integrations/__init__.py +26 -0
  41. revenium_middleware/litellm/client/integrations/crewai.py +446 -0
  42. revenium_middleware/litellm/client/middleware.py +321 -0
  43. revenium_middleware/litellm/client/summary_printer.py +314 -0
  44. revenium_middleware/litellm/client/trace_fields.py +51 -0
  45. revenium_middleware/litellm/client/validation.py +207 -0
  46. revenium_middleware/litellm/proxy/__init__.py +25 -0
  47. revenium_middleware/litellm/proxy/middleware.py +217 -0
  48. revenium_middleware/ollama/__init__.py +28 -0
  49. revenium_middleware/ollama/middleware.py +569 -0
  50. revenium_middleware/ollama/trace_fields.py +63 -0
  51. revenium_middleware/openai/__init__.py +23 -0
  52. revenium_middleware/openai/azure_config.py +169 -0
  53. revenium_middleware/openai/azure_model_resolver.py +219 -0
  54. revenium_middleware/openai/config.py +45 -0
  55. revenium_middleware/openai/exceptions.py +115 -0
  56. revenium_middleware/openai/langchain/__init__.py +114 -0
  57. revenium_middleware/openai/langchain/_utils.py +129 -0
  58. revenium_middleware/openai/langchain/unified_handler.py +526 -0
  59. revenium_middleware/openai/middleware.py +1451 -0
  60. revenium_middleware/openai/prompt_extractor.py +173 -0
  61. revenium_middleware/openai/provider.py +170 -0
  62. revenium_middleware/openai/summary_printer.py +292 -0
  63. revenium_middleware/openai/trace_fields.py +98 -0
  64. revenium_middleware/perplexity/__init__.py +97 -0
  65. revenium_middleware/perplexity/middleware.py +379 -0
  66. revenium_middleware/perplexity/perplexity_sdk.py +256 -0
  67. revenium_middleware/perplexity/provider.py +84 -0
  68. revenium_middleware/perplexity/trace_fields.py +25 -0
  69. revenium_python_sdk-0.1.0.dist-info/METADATA +252 -0
  70. revenium_python_sdk-0.1.0.dist-info/RECORD +73 -0
  71. revenium_python_sdk-0.1.0.dist-info/WHEEL +5 -0
  72. revenium_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  73. revenium_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,129 @@
1
+ """
2
+ Utility functions for LangChain integration.
3
+
4
+ This module provides helper functions for dependency checking, error handling,
5
+ and other common functionality used across the LangChain integration.
6
+ """
7
+
8
+ import functools
9
+ import logging
10
+ from typing import Any, Callable, TypeVar, Optional, Tuple
11
+
12
+ logger = logging.getLogger("revenium_middleware.langchain")
13
+
14
+ F = TypeVar('F', bound=Callable[..., Any])
15
+
16
+ # Global cache for LangChain availability check
17
+ _langchain_check_cache: Optional[Tuple[bool, Optional[Exception]]] = None
18
+
19
+
20
+ class LangChainNotInstalledError(ImportError):
21
+ """
22
+ Custom exception raised when LangChain functionality is used without LangChain installed.
23
+
24
+ This provides a clear, actionable error message for users.
25
+ """
26
+
27
+ def __init__(self, message: str = None, feature: str = None):
28
+ if message is None:
29
+ feature_msg = f" for {feature}" if feature else ""
30
+ message = (
31
+ f"LangChain is required{feature_msg} but is not installed.\n"
32
+ "Install it with one of the following commands:\n"
33
+ " pip install revenium-python-sdk[openai,langchain]\n"
34
+ " pip install langchain>=0.1.16,<1.0"
35
+ )
36
+ super().__init__(message)
37
+
38
+
39
+ def _check_langchain_cached() -> Tuple[bool, Optional[Exception]]:
40
+ """
41
+ Check if LangChain is available with caching to avoid repeated imports.
42
+
43
+ Returns:
44
+ Tuple of (is_available, import_error_if_any)
45
+ """
46
+ global _langchain_check_cache
47
+
48
+ if _langchain_check_cache is not None:
49
+ return _langchain_check_cache
50
+
51
+ try:
52
+ import langchain # noqa: F401
53
+ _langchain_check_cache = (True, None)
54
+ logger.debug("LangChain is available")
55
+ except ImportError as e:
56
+ _langchain_check_cache = (False, e)
57
+ logger.debug(f"LangChain is not available: {e}")
58
+
59
+ return _langchain_check_cache
60
+
61
+
62
+ def requires_langchain(feature: str = None) -> Callable[[F], F]:
63
+ """
64
+ Decorator that ensures LangChain is available before calling the decorated function.
65
+
66
+ Args:
67
+ feature: Optional feature name for better error messages
68
+
69
+ Returns:
70
+ Decorator function
71
+
72
+ Raises:
73
+ LangChainNotInstalledError: If LangChain is not installed
74
+ """
75
+ def decorator(func: F) -> F:
76
+ @functools.wraps(func)
77
+ def wrapper(*args, **kwargs):
78
+ is_available, import_error = _check_langchain_cached()
79
+ if not is_available:
80
+ raise LangChainNotInstalledError(feature=feature) from import_error
81
+
82
+ return func(*args, **kwargs)
83
+
84
+ return wrapper
85
+ return decorator
86
+
87
+
88
+ def check_langchain_version() -> str:
89
+ """
90
+ Check the installed LangChain version and return it.
91
+
92
+ Returns:
93
+ LangChain version string
94
+
95
+ Raises:
96
+ LangChainNotInstalledError: If LangChain is not installed
97
+ """
98
+ is_available, import_error = _check_langchain_cached()
99
+ if not is_available:
100
+ raise LangChainNotInstalledError(feature="version checking") from import_error
101
+
102
+ import langchain # noqa: F401
103
+ return getattr(langchain, '__version__', 'unknown')
104
+
105
+
106
+ def is_langchain_available() -> bool:
107
+ """
108
+ Check if LangChain is available without raising an exception.
109
+
110
+ Returns:
111
+ True if LangChain is available, False otherwise
112
+ """
113
+ is_available, _ = _check_langchain_cached()
114
+ return is_available
115
+
116
+
117
+ def require_langchain_or_raise(feature: str = None) -> None:
118
+ """
119
+ Ensure LangChain is available, raising a helpful error if not.
120
+
121
+ Args:
122
+ feature: Optional feature name for better error messages
123
+
124
+ Raises:
125
+ LangChainNotInstalledError: If LangChain is not installed
126
+ """
127
+ is_available, import_error = _check_langchain_cached()
128
+ if not is_available:
129
+ raise LangChainNotInstalledError(feature=feature) from import_error
@@ -0,0 +1,526 @@
1
+ """
2
+ Unified LangChain callback handler implementation for Revenium middleware.
3
+
4
+ This module implements the unified architecture documented in LANGCHAIN_ARCHITECTURE.md,
5
+ providing a single ReveniumCallbackHandler that supports both sync and async operations.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ logger = logging.getLogger("revenium_middleware.langchain")
13
+
14
+ # Version compatibility pattern - support both LangChain 0.x and 1.0+
15
+ try:
16
+ # LangChain 1.0+ uses langchain_core
17
+ from langchain_core.callbacks.base import BaseCallbackHandler, AsyncCallbackHandler
18
+ except ImportError:
19
+ try:
20
+ # LangChain 0.x uses langchain.callbacks.base
21
+ from langchain.callbacks.base import BaseCallbackHandler, AsyncCallbackHandler
22
+ except ImportError:
23
+ from langchain.callbacks.base import BaseCallbackHandler
24
+ class AsyncCallbackHandler(BaseCallbackHandler): # shim for very old LC
25
+ pass
26
+
27
+
28
+ def _safe(fn):
29
+ """
30
+ Decorator to safely wrap callback methods and prevent errors from propagating.
31
+
32
+ This is a core part of the unified architecture - ensures callback errors
33
+ never break user scripts.
34
+ """
35
+ def wrapper(self, *args, **kwargs):
36
+ try:
37
+ return fn(self, *args, **kwargs)
38
+ except Exception as e:
39
+ logger.exception(f"Revenium callback failed in {fn.__name__}: {e}")
40
+ # Never re-raise - graceful degradation
41
+ return wrapper
42
+
43
+
44
+ class UnifiedReveniumCallbackHandler(AsyncCallbackHandler):
45
+ """
46
+ Unified LangChain callback handler that integrates with Revenium usage tracking.
47
+
48
+ This handler implements both sync and async callback interfaces, allowing it to
49
+ work seamlessly with any LangChain operation. It captures LLM interactions and
50
+ forwards usage data to Revenium for cost tracking and analytics.
51
+
52
+ Features:
53
+ - Unified sync/async support (no handler selection needed)
54
+ - Automatic usage tracking for chat completions
55
+ - Transport-level embeddings tracking (via x-revenium-origin header)
56
+ - Support for streaming responses
57
+ - Configurable metadata injection
58
+ - Error isolation with @_safe decorator
59
+ - Graceful degradation when tracking fails
60
+
61
+ Architecture:
62
+ - Implements both BaseCallbackHandler and AsyncCallbackHandler
63
+ - Uses shared implementation helpers for consistency
64
+ - LangChain's callback manager handles sync/async routing automatically
65
+ - Transport hooks handle embeddings (no callback overhead)
66
+ """
67
+
68
+ def __init__(self,
69
+ usage_metadata: Optional[dict] = None,
70
+ enable_debug_logging: bool = False):
71
+ """
72
+ Initialize the unified Revenium callback handler.
73
+
74
+ Args:
75
+ usage_metadata: Optional metadata to include with usage tracking
76
+ enable_debug_logging: Enable detailed debug logging for troubleshooting
77
+ """
78
+ # Initialize the AsyncCallbackHandler (which includes BaseCallbackHandler)
79
+ super().__init__()
80
+
81
+ # Store configuration
82
+ self.usage_metadata = usage_metadata or {}
83
+ self.enable_debug_logging = enable_debug_logging
84
+
85
+ # Internal state for tracking operations
86
+ self._active_runs = {} # Track active LLM runs by run_id
87
+ self._operation_timings = {} # Track timing information
88
+
89
+ # Import Revenium dependencies
90
+ self._import_revenium_dependencies()
91
+
92
+ if self.enable_debug_logging:
93
+ logger.info("Unified ReveniumCallbackHandler created successfully")
94
+
95
+ def _import_revenium_dependencies(self):
96
+ """Import Revenium middleware dependencies with fallback."""
97
+ try:
98
+ # Import the correct functions from the middleware
99
+ from revenium_middleware.openai.middleware import create_metering_call, OperationType
100
+ from revenium_middleware.openai.provider import get_or_detect_provider
101
+
102
+ self._create_metering_call = create_metering_call
103
+ self._OperationType = OperationType
104
+ self._get_or_detect_provider = get_or_detect_provider
105
+
106
+ if self.enable_debug_logging:
107
+ logger.debug("Revenium middleware dependencies imported successfully")
108
+
109
+ except ImportError as e:
110
+ if self.enable_debug_logging:
111
+ logger.debug(f"Revenium middleware not available: {e}")
112
+ self._create_metering_call = self._fallback_metering_call
113
+ self._OperationType = None
114
+ self._get_or_detect_provider = None
115
+
116
+ def _fallback_metering_call(self, *args, **kwargs):
117
+ """Fallback function when Revenium middleware is not available."""
118
+ logger.warning("Revenium middleware not available - usage tracking disabled")
119
+ return None
120
+
121
+ # ---- Shared Implementation Helpers ----
122
+
123
+ def _handle_llm_start(self, serialized: Dict[str, Any], prompts: List[str],
124
+ is_async: bool = False, **kwargs) -> None:
125
+ """
126
+ Shared implementation for LLM start events.
127
+
128
+ Args:
129
+ serialized: Serialized LLM configuration
130
+ prompts: List of prompts being sent to the LLM
131
+ is_async: Whether this is an async operation
132
+ **kwargs: Additional keyword arguments including run_id
133
+ """
134
+ run_id = kwargs.get('run_id')
135
+ if not run_id:
136
+ return
137
+
138
+ # Store run information for later processing
139
+ run_info = {
140
+ 'start_time': time.time(),
141
+ 'serialized': serialized,
142
+ 'prompts': prompts,
143
+ 'is_async': is_async,
144
+ 'usage_metadata': self.usage_metadata.copy()
145
+ }
146
+
147
+ self._active_runs[run_id] = run_info
148
+
149
+ if self.enable_debug_logging:
150
+ context = "async" if is_async else "sync"
151
+ logger.debug(f"LLM started ({context}) - run_id: {run_id}")
152
+
153
+ def _handle_llm_end(self, response: Any, is_async: bool = False, **kwargs) -> None:
154
+ """
155
+ Shared implementation for LLM end events.
156
+
157
+ Args:
158
+ response: LLM response object
159
+ is_async: Whether this is an async operation
160
+ **kwargs: Additional keyword arguments including run_id
161
+ """
162
+ run_id = kwargs.get('run_id')
163
+ if not run_id or run_id not in self._active_runs:
164
+ return
165
+
166
+ run_info = self._active_runs.pop(run_id)
167
+
168
+ # Process the response and create metering call
169
+ self._process_llm_response(response, run_info)
170
+
171
+ if self.enable_debug_logging:
172
+ context = "async" if is_async else "sync"
173
+ logger.debug(f"LLM ended ({context}) - run_id: {run_id}")
174
+
175
+ def _process_llm_response(self, response: Any, run_info: Dict[str, Any]) -> None:
176
+ """
177
+ Process LLM response and create Revenium metering call.
178
+
179
+ Args:
180
+ response: LLM response object
181
+ run_info: Stored information from the start of the run
182
+ """
183
+ try:
184
+ # Extract usage information from response
185
+ usage_data = self._extract_usage_from_response(response)
186
+
187
+ if usage_data:
188
+ # Create metering call
189
+ self._process_metering_call(usage_data, run_info)
190
+ else:
191
+ logger.warning("No usage data found in LLM response")
192
+
193
+ except Exception as e:
194
+ logger.error(f"Error processing LLM response: {e}")
195
+
196
+ def _extract_usage_from_response(self, response: Any) -> Optional[Dict[str, Any]]:
197
+ """
198
+ Extract usage information from LLM response.
199
+
200
+ Args:
201
+ response: LLM response object
202
+
203
+ Returns:
204
+ Dictionary with usage information or None if not found
205
+ """
206
+ # Try different ways to extract usage data
207
+ usage_data = None
208
+
209
+ if self.enable_debug_logging:
210
+ logger.debug(f"Extracting usage from response type: {type(response)}")
211
+ logger.debug(f"Response attributes: {dir(response)}")
212
+
213
+ # Check for usage_metadata in response (LangChain v0.2+)
214
+ if hasattr(response, 'usage_metadata') and response.usage_metadata:
215
+ usage_data = response.usage_metadata
216
+ if self.enable_debug_logging:
217
+ logger.debug(f"Found usage_metadata: {usage_data}")
218
+
219
+ # Check for response_metadata with token_usage (LangChain v0.1+)
220
+ elif hasattr(response, 'response_metadata') and response.response_metadata:
221
+ if 'token_usage' in response.response_metadata:
222
+ usage_data = response.response_metadata['token_usage']
223
+ if self.enable_debug_logging:
224
+ logger.debug(f"Found token_usage in response_metadata: {usage_data}")
225
+ elif 'usage' in response.response_metadata:
226
+ usage_data = response.response_metadata['usage']
227
+ if self.enable_debug_logging:
228
+ logger.debug(f"Found usage in response_metadata: {usage_data}")
229
+
230
+ # Check for llm_output (older LangChain versions)
231
+ elif hasattr(response, 'llm_output') and response.llm_output:
232
+ if 'token_usage' in response.llm_output:
233
+ usage_data = response.llm_output['token_usage']
234
+ if self.enable_debug_logging:
235
+ logger.debug(f"Found token_usage in llm_output: {usage_data}")
236
+ elif 'usage' in response.llm_output:
237
+ usage_data = response.llm_output['usage']
238
+ if self.enable_debug_logging:
239
+ logger.debug(f"Found usage in llm_output: {usage_data}")
240
+
241
+ # For streaming responses, check if there's accumulated usage data
242
+ elif hasattr(response, 'content') and hasattr(response, 'additional_kwargs'):
243
+ # This might be a streaming chunk with accumulated data
244
+ if 'usage' in response.additional_kwargs:
245
+ usage_data = response.additional_kwargs['usage']
246
+ if self.enable_debug_logging:
247
+ logger.debug(f"Found usage in additional_kwargs: {usage_data}")
248
+
249
+ # Last resort: check for any 'usage' attribute directly
250
+ elif hasattr(response, 'usage'):
251
+ usage_data = response.usage
252
+ if self.enable_debug_logging:
253
+ logger.debug(f"Found direct usage attribute: {usage_data}")
254
+
255
+ if not usage_data and self.enable_debug_logging:
256
+ logger.debug("No usage data found in response")
257
+ # Log response structure for debugging
258
+ if hasattr(response, '__dict__'):
259
+ logger.debug(f"Response dict: {response.__dict__}")
260
+
261
+ return usage_data
262
+
263
+ def _process_metering_call(self, usage_data: Dict[str, Any], run_info: Dict[str, Any]) -> None:
264
+ """
265
+ Create a Revenium metering call with the usage data.
266
+
267
+ Args:
268
+ usage_data: Usage information extracted from response
269
+ run_info: Stored information from the start of the run
270
+ """
271
+ try:
272
+ # Create a mock response object that matches what the middleware expects
273
+ mock_response = self._create_mock_response(usage_data, run_info)
274
+
275
+ # Calculate request time
276
+ import datetime
277
+ request_time_dt = datetime.datetime.fromtimestamp(run_info['start_time'], tz=datetime.timezone.utc)
278
+
279
+ # Determine operation type
280
+ operation_type = self._OperationType.CHAT if self._OperationType else None
281
+
282
+ if operation_type and self._create_metering_call != self._fallback_metering_call:
283
+ # Use the unified create_metering_call function from middleware
284
+ # Signature: create_metering_call(response, operation_type, request_time_dt, usage_metadata, client_instance=None, time_to_first_token=0, is_streamed=False)
285
+ result = self._create_metering_call(
286
+ mock_response, # response
287
+ operation_type, # operation_type
288
+ request_time_dt, # request_time_dt
289
+ run_info['usage_metadata'], # usage_metadata
290
+ None, # client_instance - LangChain doesn't provide direct client access
291
+ 0, # time_to_first_token - Not available from LangChain callbacks
292
+ run_info.get('is_streaming', False) # is_streamed
293
+ )
294
+
295
+ if self.enable_debug_logging:
296
+ logger.debug(f"Revenium metering call successful: {result}")
297
+ else:
298
+ logger.warning("OperationType not available - metering call skipped")
299
+
300
+ except Exception as e:
301
+ logger.error(f"Error creating Revenium metering call: {e}")
302
+ if self.enable_debug_logging:
303
+ import traceback
304
+ logger.debug(f"Metering call traceback: {traceback.format_exc()}")
305
+
306
+ def _create_mock_response(self, usage_data: Dict[str, Any], run_info: Dict[str, Any]) -> Any:
307
+ """
308
+ Create a mock response object that matches what the middleware expects.
309
+
310
+ Args:
311
+ usage_data: Usage information from LangChain response
312
+ run_info: Stored information from the start of the run
313
+
314
+ Returns:
315
+ Mock response object with required attributes
316
+ """
317
+ class MockResponse:
318
+ def __init__(self, usage_data, model_name):
319
+ # Set usage information in the format expected by middleware
320
+ if 'input_tokens' in usage_data:
321
+ # LangChain v0.2+ format
322
+ self.usage = type('Usage', (), {
323
+ 'prompt_tokens': usage_data.get('input_tokens', 0),
324
+ 'completion_tokens': usage_data.get('output_tokens', 0),
325
+ 'total_tokens': usage_data.get('total_tokens', 0)
326
+ })()
327
+ elif 'prompt_tokens' in usage_data:
328
+ # OpenAI format
329
+ self.usage = type('Usage', (), {
330
+ 'prompt_tokens': usage_data.get('prompt_tokens', 0),
331
+ 'completion_tokens': usage_data.get('completion_tokens', 0),
332
+ 'total_tokens': usage_data.get('total_tokens', 0)
333
+ })()
334
+ else:
335
+ # Fallback - create minimal usage
336
+ self.usage = type('Usage', (), {
337
+ 'prompt_tokens': 0,
338
+ 'completion_tokens': 0,
339
+ 'total_tokens': 0
340
+ })()
341
+
342
+ # Set model name
343
+ self.model = model_name
344
+
345
+ # Generate a unique ID for this response
346
+ import uuid
347
+ self.id = f"langchain-{uuid.uuid4().hex[:8]}"
348
+
349
+ # Set other expected attributes
350
+ self.object = "chat.completion"
351
+ self.created = int(time.time())
352
+ self.system_fingerprint = None
353
+
354
+ # Add choices array (required by middleware)
355
+ choice = type('Choice', (), {
356
+ 'index': 0,
357
+ 'message': type('Message', (), {
358
+ 'role': 'assistant',
359
+ 'content': 'Mock response from LangChain callback'
360
+ })(),
361
+ 'finish_reason': 'stop'
362
+ })()
363
+ self.choices = [choice]
364
+
365
+ model_name = self._extract_model_name(run_info['serialized'])
366
+ return MockResponse(usage_data, model_name)
367
+
368
+ def _extract_model_name(self, serialized: Dict[str, Any]) -> str:
369
+ """
370
+ Extract model name from serialized LLM configuration.
371
+
372
+ Args:
373
+ serialized: Serialized LLM configuration
374
+
375
+ Returns:
376
+ Model name string
377
+ """
378
+ if self.enable_debug_logging:
379
+ logger.debug(f"Extracting model name from serialized: {serialized}")
380
+
381
+ # Try different ways to extract model name from LangChain serialized data
382
+ model_name = None
383
+
384
+ # Check direct model fields
385
+ if 'model_name' in serialized:
386
+ model_name = serialized['model_name']
387
+ elif 'model' in serialized:
388
+ model_name = serialized['model']
389
+
390
+ # Check in kwargs (common in LangChain)
391
+ elif 'kwargs' in serialized and isinstance(serialized['kwargs'], dict):
392
+ kwargs = serialized['kwargs']
393
+ if 'model_name' in kwargs:
394
+ model_name = kwargs['model_name']
395
+ elif 'model' in kwargs:
396
+ model_name = kwargs['model']
397
+
398
+ # Check in id field (sometimes contains class info)
399
+ elif 'id' in serialized and isinstance(serialized['id'], list):
400
+ # LangChain often stores class path in id field
401
+ id_parts = serialized['id']
402
+ for part in id_parts:
403
+ if isinstance(part, str) and ('gpt' in part.lower() or 'claude' in part.lower() or 'llama' in part.lower()):
404
+ model_name = part
405
+ break
406
+
407
+ # Fallback to unknown
408
+ if not model_name:
409
+ model_name = 'unknown'
410
+ if self.enable_debug_logging:
411
+ logger.warning(f"Could not extract model name from serialized data: {serialized}")
412
+
413
+ if self.enable_debug_logging:
414
+ logger.debug(f"Extracted model name: {model_name}")
415
+
416
+ return str(model_name)
417
+
418
+ # ---- Sync Callback Methods ----
419
+
420
+ @_safe
421
+ def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str], **kwargs) -> None:
422
+ """Sync callback for LLM start."""
423
+ self._handle_llm_start(serialized, prompts, is_async=False, **kwargs)
424
+
425
+ @_safe
426
+ def on_llm_end(self, response: Any, **kwargs) -> None:
427
+ """Sync callback for LLM end."""
428
+ self._handle_llm_end(response, is_async=False, **kwargs)
429
+
430
+ @_safe
431
+ def on_llm_error(self, error: Exception, **kwargs) -> None:
432
+ """Sync callback for LLM error."""
433
+ run_id = kwargs.get('run_id')
434
+ if run_id and run_id in self._active_runs:
435
+ self._active_runs.pop(run_id)
436
+ logger.warning(f"LLM error in run {run_id}: {error}")
437
+
438
+ @_safe
439
+ def on_chat_model_start(self, serialized: Dict[str, Any], messages: List[List], **kwargs) -> None:
440
+ """Sync callback for chat model start (LangChain specific)."""
441
+ # Convert messages to prompts format for consistency
442
+ prompts = []
443
+ for message_list in messages:
444
+ if isinstance(message_list, list):
445
+ # Extract content from message objects
446
+ prompt_parts = []
447
+ for msg in message_list:
448
+ if hasattr(msg, 'content'):
449
+ prompt_parts.append(str(msg.content))
450
+ else:
451
+ prompt_parts.append(str(msg))
452
+ prompts.append(" ".join(prompt_parts))
453
+ else:
454
+ prompts.append(str(message_list))
455
+
456
+ self._handle_llm_start(serialized, prompts, is_async=False, **kwargs)
457
+
458
+ # ---- Async Callback Methods ----
459
+
460
+ @_safe
461
+ async def on_llm_start_async(self, serialized: Dict[str, Any], prompts: List[str], **kwargs) -> None:
462
+ """Async callback for LLM start."""
463
+ self._handle_llm_start(serialized, prompts, is_async=True, **kwargs)
464
+
465
+ @_safe
466
+ async def on_llm_end_async(self, response: Any, **kwargs) -> None:
467
+ """Async callback for LLM end."""
468
+ self._handle_llm_end(response, is_async=True, **kwargs)
469
+
470
+ @_safe
471
+ async def on_llm_error_async(self, error: Exception, **kwargs) -> None:
472
+ """Async callback for LLM error."""
473
+ run_id = kwargs.get('run_id')
474
+ if run_id and run_id in self._active_runs:
475
+ self._active_runs.pop(run_id)
476
+ logger.warning(f"LLM error in async run {run_id}: {error}")
477
+
478
+ @_safe
479
+ async def on_chat_model_start_async(self, serialized: Dict[str, Any], messages: List[List], **kwargs) -> None:
480
+ """Async callback for chat model start (LangChain specific)."""
481
+ # Convert messages to prompts format for consistency
482
+ prompts = []
483
+ for message_list in messages:
484
+ if isinstance(message_list, list):
485
+ # Extract content from message objects
486
+ prompt_parts = []
487
+ for msg in message_list:
488
+ if hasattr(msg, 'content'):
489
+ prompt_parts.append(str(msg.content))
490
+ else:
491
+ prompt_parts.append(str(msg))
492
+ prompts.append(" ".join(prompt_parts))
493
+ else:
494
+ prompts.append(str(message_list))
495
+
496
+ self._handle_llm_start(serialized, prompts, is_async=True, **kwargs)
497
+
498
+ # ---- Stub Methods for Other Callbacks ----
499
+
500
+ def on_chain_start(self, *args, **kwargs): pass
501
+ def on_chain_end(self, *args, **kwargs): pass
502
+ def on_chain_error(self, *args, **kwargs): pass
503
+ def on_tool_start(self, *args, **kwargs): pass
504
+ def on_tool_end(self, *args, **kwargs): pass
505
+ def on_tool_error(self, *args, **kwargs): pass
506
+ def on_text(self, *args, **kwargs): pass
507
+ def on_agent_action(self, *args, **kwargs): pass
508
+ def on_agent_finish(self, *args, **kwargs): pass
509
+
510
+ # Async versions (delegate to sync)
511
+ async def on_chain_start_async(self, *args, **kwargs): return self.on_chain_start(*args, **kwargs)
512
+ async def on_chain_end_async(self, *args, **kwargs): return self.on_chain_end(*args, **kwargs)
513
+ async def on_chain_error_async(self, *args, **kwargs): return self.on_chain_error(*args, **kwargs)
514
+ async def on_tool_start_async(self, *args, **kwargs): return self.on_tool_start(*args, **kwargs)
515
+ async def on_tool_end_async(self, *args, **kwargs): return self.on_tool_end(*args, **kwargs)
516
+ async def on_tool_error_async(self, *args, **kwargs): return self.on_tool_error(*args, **kwargs)
517
+
518
+ def get_stats(self) -> Dict[str, Any]:
519
+ """Get statistics about the handler's operation."""
520
+ return {
521
+ 'active_runs': len(self._active_runs),
522
+ 'total_operations': len(self._operation_timings),
523
+ 'has_revenium_middleware': (
524
+ self._create_metering_call != self._fallback_metering_call
525
+ )
526
+ }