fenra 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.
@@ -0,0 +1,529 @@
1
+ """Gemini integration with auto-instrumentation via monkey patching."""
2
+
3
+ import functools
4
+ import logging
5
+ from typing import Any
6
+
7
+ from fenra._context import get_context
8
+ from fenra._core import enqueue_transaction
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _patched_sync = False
13
+ _patched_async = False
14
+ _patched_stream_sync = False
15
+ _patched_stream_async = False
16
+
17
+ # Store original methods for unpatching
18
+ _original_generate_content: Any = None
19
+ _original_generate_content_async: Any = None
20
+ _original_generate_content_stream: Any = None
21
+ _original_generate_content_stream_async: Any = None
22
+
23
+
24
+ def _track_generate_content_response(
25
+ response: Any,
26
+ model: str,
27
+ context: dict[str, Any],
28
+ config: Any = None,
29
+ ) -> None:
30
+ """
31
+ Extract usage from GenerateContentResponse and queue transaction.
32
+
33
+ Handles:
34
+ - Token usage (including thinking tokens for reasoning models)
35
+ - Google Search tool usage (web_search requests)
36
+ - Image generation output
37
+ """
38
+ if not hasattr(response, "usage_metadata") or response.usage_metadata is None:
39
+ return
40
+
41
+ usage = response.usage_metadata
42
+
43
+ # Build token metrics
44
+ # thoughts_token_count is used by thinking models like gemini-2.0-flash-thinking
45
+ output_tokens = (getattr(usage, "candidates_token_count", 0) or 0) + (
46
+ getattr(usage, "thoughts_token_count", None) or 0
47
+ )
48
+
49
+ metrics: dict[str, Any] = {
50
+ "input_tokens": getattr(usage, "prompt_token_count", 0) or 0,
51
+ "output_tokens": output_tokens,
52
+ "total_tokens": getattr(usage, "total_token_count", 0) or 0,
53
+ }
54
+
55
+ # Include cached tokens if present
56
+ cached = getattr(usage, "cached_content_token_count", None)
57
+ if cached:
58
+ metrics["cached_tokens"] = cached
59
+
60
+ usage_entries: list[dict[str, Any]] = [
61
+ {
62
+ "type": "tokens",
63
+ "metrics": metrics,
64
+ }
65
+ ]
66
+
67
+ # Detect Google Search tool usage
68
+ search_usage = _detect_tool_usage(response, config)
69
+ if search_usage:
70
+ usage_entries.append(search_usage)
71
+
72
+ # Detect image generation output
73
+ image_usage = _detect_image_output(response, config)
74
+ if image_usage:
75
+ usage_entries.append(image_usage)
76
+
77
+ transaction: dict[str, Any] = {
78
+ "provider": "gemini",
79
+ "model": model,
80
+ "usage": usage_entries,
81
+ "context": context,
82
+ }
83
+
84
+ # Include raw usage for debugging if available
85
+ try:
86
+ transaction["provider_usage_raw"] = {
87
+ "prompt_token_count": getattr(usage, "prompt_token_count", None),
88
+ "candidates_token_count": getattr(usage, "candidates_token_count", None),
89
+ "total_token_count": getattr(usage, "total_token_count", None),
90
+ "thoughts_token_count": getattr(usage, "thoughts_token_count", None),
91
+ "cached_content_token_count": getattr(
92
+ usage, "cached_content_token_count", None
93
+ ),
94
+ }
95
+ except Exception:
96
+ pass
97
+
98
+ enqueue_transaction(transaction)
99
+
100
+
101
+ def _detect_tool_usage(response: Any, config: Any) -> dict[str, Any] | None:
102
+ """
103
+ Detect Google Search tool usage from grounding metadata.
104
+
105
+ Returns a requests usage entry if web search was used, None otherwise.
106
+ """
107
+ if config is None:
108
+ return None
109
+
110
+ # Check if GoogleSearch tool was configured
111
+ tools = getattr(config, "tools", None)
112
+ if not tools:
113
+ return None
114
+
115
+ has_google_search = False
116
+ for tool in tools:
117
+ if hasattr(tool, "google_search") and tool.google_search is not None:
118
+ has_google_search = True
119
+ break
120
+
121
+ if not has_google_search:
122
+ return None
123
+
124
+ # Extract search count from grounding metadata
125
+ search_count = 0
126
+ candidates = getattr(response, "candidates", None) or []
127
+ for candidate in candidates:
128
+ grounding_metadata = getattr(candidate, "grounding_metadata", None)
129
+ if grounding_metadata:
130
+ web_search_queries = getattr(grounding_metadata, "web_search_queries", None)
131
+ if web_search_queries:
132
+ search_count = len(web_search_queries)
133
+ break
134
+
135
+ # Default to 1 if tool was configured but we couldn't detect count
136
+ if search_count == 0:
137
+ search_count = 1
138
+
139
+ return {
140
+ "type": "requests",
141
+ "metrics": {"count": search_count, "request_type": "web_search"},
142
+ }
143
+
144
+
145
+ def _detect_image_output(response: Any, config: Any) -> dict[str, Any] | None:
146
+ """
147
+ Detect image generation from response parts.
148
+
149
+ Returns an images usage entry if images were generated, None otherwise.
150
+ """
151
+ if config is None:
152
+ return None
153
+
154
+ # Check if IMAGE was in response modalities
155
+ response_modalities = getattr(config, "response_modalities", None)
156
+ if not response_modalities or "IMAGE" not in response_modalities:
157
+ return None
158
+
159
+ # Count images from response parts
160
+ image_count = 0
161
+ candidates = getattr(response, "candidates", None) or []
162
+ for candidate in candidates:
163
+ content = getattr(candidate, "content", None)
164
+ if content:
165
+ parts = getattr(content, "parts", None) or []
166
+ for part in parts:
167
+ if hasattr(part, "inline_data") and part.inline_data:
168
+ image_count += 1
169
+
170
+ if image_count == 0:
171
+ return None
172
+
173
+ return {
174
+ "type": "images",
175
+ "metrics": {
176
+ "generated": image_count,
177
+ "size_px": 1024, # Default Gemini image size
178
+ "quality": "standard",
179
+ },
180
+ }
181
+
182
+
183
+ def _track_stream(
184
+ stream: Any,
185
+ model: str,
186
+ context: dict[str, Any],
187
+ config: Any = None,
188
+ ) -> Any:
189
+ """
190
+ Wrap a streaming response to track usage from the final chunk.
191
+
192
+ Returns a wrapped stream that tracks usage when the stream is exhausted.
193
+ """
194
+ last_chunk = None
195
+
196
+ def wrapped_stream() -> Any:
197
+ nonlocal last_chunk
198
+ try:
199
+ for chunk in stream:
200
+ last_chunk = chunk
201
+ yield chunk
202
+ finally:
203
+ # After stream is exhausted, track usage from final chunk
204
+ if last_chunk:
205
+ try:
206
+ _track_generate_content_response(last_chunk, model, context, config)
207
+ except Exception as e:
208
+ logger.error(
209
+ f"Error tracking Gemini streaming usage: {e}", exc_info=True
210
+ )
211
+
212
+ return wrapped_stream()
213
+
214
+
215
+ async def _track_stream_async(
216
+ stream: Any,
217
+ model: str,
218
+ context: dict[str, Any],
219
+ config: Any = None,
220
+ ) -> Any:
221
+ """
222
+ Wrap an async streaming response to track usage from the final chunk.
223
+
224
+ Returns a wrapped async stream that tracks usage when the stream is exhausted.
225
+ """
226
+ last_chunk = None
227
+
228
+ try:
229
+ async for chunk in stream:
230
+ last_chunk = chunk
231
+ yield chunk
232
+ finally:
233
+ # After stream is exhausted, track usage from final chunk
234
+ if last_chunk:
235
+ try:
236
+ _track_generate_content_response(last_chunk, model, context, config)
237
+ except Exception as e:
238
+ logger.error(
239
+ f"Error tracking Gemini streaming usage: {e}", exc_info=True
240
+ )
241
+
242
+
243
+ # ============================================================================
244
+ # Sync Patching
245
+ # ============================================================================
246
+
247
+
248
+ def patch_gemini() -> None:
249
+ """
250
+ Patch Gemini client to auto-track usage.
251
+
252
+ This patches the synchronous `Models.generate_content` method.
253
+ """
254
+ global _patched_sync, _original_generate_content
255
+
256
+ if _patched_sync:
257
+ logger.debug("Gemini sync already patched, skipping")
258
+ return
259
+
260
+ try:
261
+ from google.genai.models import Models
262
+ except ImportError:
263
+ logger.warning(
264
+ "Gemini SDK not installed. Install with: pip install 'fenra[gemini]'"
265
+ )
266
+ return
267
+
268
+ original_generate_content = Models.generate_content
269
+ _original_generate_content = original_generate_content
270
+
271
+ @functools.wraps(original_generate_content)
272
+ def patched_generate_content(self: Models, *args: Any, **kwargs: Any) -> Any:
273
+ response = original_generate_content(self, *args, **kwargs)
274
+
275
+ context = get_context()
276
+
277
+ model = kwargs.get("model") or (args[0] if args else "unknown")
278
+
279
+ config = kwargs.get("config")
280
+
281
+ try:
282
+ _track_generate_content_response(response, model, context, config)
283
+ except Exception as e:
284
+ logger.error(f"Error tracking Gemini usage: {e}", exc_info=True)
285
+
286
+ return response
287
+
288
+ Models.generate_content = patched_generate_content # type: ignore[assignment]
289
+ _patched_sync = True
290
+ logger.info("Gemini SDK patched for auto-instrumentation")
291
+
292
+
293
+ def patch_gemini_stream() -> None:
294
+ """
295
+ Patch Gemini client to auto-track streaming usage.
296
+
297
+ This patches the synchronous `Models.generate_content_stream` method.
298
+ """
299
+ global _patched_stream_sync, _original_generate_content_stream
300
+
301
+ if _patched_stream_sync:
302
+ logger.debug("Gemini sync stream already patched, skipping")
303
+ return
304
+
305
+ try:
306
+ from google.genai.models import Models
307
+ except ImportError:
308
+ logger.warning(
309
+ "Gemini SDK not installed. Install with: pip install 'fenra[gemini]'"
310
+ )
311
+ return
312
+
313
+ if not hasattr(Models, "generate_content_stream"):
314
+ logger.debug("Gemini Models has no generate_content_stream method, skipping")
315
+ return
316
+
317
+ original_generate_content_stream = Models.generate_content_stream
318
+ _original_generate_content_stream = original_generate_content_stream
319
+
320
+ @functools.wraps(original_generate_content_stream)
321
+ def patched_generate_content_stream(
322
+ self: Models, *args: Any, **kwargs: Any
323
+ ) -> Any:
324
+ stream = original_generate_content_stream(self, *args, **kwargs)
325
+
326
+ context = get_context()
327
+
328
+ model = kwargs.get("model") or (args[0] if args else "unknown")
329
+
330
+ config = kwargs.get("config")
331
+
332
+ try:
333
+ return _track_stream(stream, model, context, config)
334
+ except Exception as e:
335
+ logger.error(f"Error wrapping Gemini stream: {e}", exc_info=True)
336
+ return stream
337
+
338
+ Models.generate_content_stream = patched_generate_content_stream # type: ignore[assignment]
339
+ _patched_stream_sync = True
340
+ logger.info("Gemini streaming SDK patched for auto-instrumentation")
341
+
342
+
343
+ # ============================================================================
344
+ # Async Patching
345
+ # ============================================================================
346
+
347
+
348
+ def patch_gemini_async() -> None:
349
+ """
350
+ Patch Gemini async client to auto-track usage.
351
+
352
+ This patches the asynchronous `AsyncModels.generate_content` method.
353
+ """
354
+ global _patched_async, _original_generate_content_async
355
+
356
+ if _patched_async:
357
+ logger.debug("Gemini async already patched, skipping")
358
+ return
359
+
360
+ try:
361
+ from google.genai.models import AsyncModels
362
+ except ImportError:
363
+ logger.warning(
364
+ "Gemini SDK not installed. Install with: pip install 'fenra[gemini]'"
365
+ )
366
+ return
367
+
368
+ original_generate_content = AsyncModels.generate_content
369
+ _original_generate_content_async = original_generate_content
370
+
371
+ @functools.wraps(original_generate_content)
372
+ async def patched_generate_content(
373
+ self: AsyncModels, *args: Any, **kwargs: Any
374
+ ) -> Any:
375
+ response = await original_generate_content(self, *args, **kwargs)
376
+
377
+ context = get_context()
378
+
379
+ model = kwargs.get("model") or (args[0] if args else "unknown")
380
+
381
+ config = kwargs.get("config")
382
+
383
+ try:
384
+ _track_generate_content_response(response, model, context, config)
385
+ except Exception as e:
386
+ logger.error(f"Error tracking Gemini async usage: {e}", exc_info=True)
387
+
388
+ return response
389
+
390
+ AsyncModels.generate_content = patched_generate_content # type: ignore[assignment]
391
+ _patched_async = True
392
+ logger.info("Gemini async SDK patched for auto-instrumentation")
393
+
394
+
395
+ def patch_gemini_stream_async() -> None:
396
+ """
397
+ Patch Gemini async client to auto-track streaming usage.
398
+
399
+ This patches the asynchronous `AsyncModels.generate_content_stream` method.
400
+ """
401
+ global _patched_stream_async, _original_generate_content_stream_async
402
+
403
+ if _patched_stream_async:
404
+ logger.debug("Gemini async stream already patched, skipping")
405
+ return
406
+
407
+ try:
408
+ from google.genai.models import AsyncModels
409
+ except ImportError:
410
+ logger.warning(
411
+ "Gemini SDK not installed. Install with: pip install 'fenra[gemini]'"
412
+ )
413
+ return
414
+
415
+ if not hasattr(AsyncModels, "generate_content_stream"):
416
+ logger.debug(
417
+ "Gemini AsyncModels has no generate_content_stream method, skipping"
418
+ )
419
+ return
420
+
421
+ original_generate_content_stream = AsyncModels.generate_content_stream
422
+ _original_generate_content_stream_async = original_generate_content_stream
423
+
424
+ @functools.wraps(original_generate_content_stream)
425
+ async def patched_generate_content_stream(
426
+ self: AsyncModels, *args: Any, **kwargs: Any
427
+ ) -> Any:
428
+ stream = await original_generate_content_stream(self, *args, **kwargs)
429
+
430
+ context = get_context()
431
+
432
+ model = kwargs.get("model") or (args[0] if args else "unknown")
433
+
434
+ config = kwargs.get("config")
435
+
436
+ try:
437
+ return _track_stream_async(stream, model, context, config)
438
+ except Exception as e:
439
+ logger.error(f"Error wrapping Gemini async stream: {e}", exc_info=True)
440
+ return stream
441
+
442
+ AsyncModels.generate_content_stream = patched_generate_content_stream # type: ignore[assignment]
443
+ _patched_stream_async = True
444
+ logger.info("Gemini async streaming SDK patched for auto-instrumentation")
445
+
446
+
447
+ # ============================================================================
448
+ # Unpatch Functions
449
+ # ============================================================================
450
+
451
+
452
+ def unpatch_gemini() -> None:
453
+ """Restore original Gemini Models.generate_content method."""
454
+ global _patched_sync, _original_generate_content
455
+
456
+ if not _patched_sync or _original_generate_content is None:
457
+ return
458
+
459
+ try:
460
+ from google.genai.models import Models
461
+
462
+ Models.generate_content = _original_generate_content # type: ignore[assignment]
463
+ _patched_sync = False
464
+ _original_generate_content = None
465
+ logger.info("Gemini SDK unpatched")
466
+ except ImportError:
467
+ pass
468
+
469
+
470
+ def unpatch_gemini_stream() -> None:
471
+ """Restore original Gemini Models.generate_content_stream method."""
472
+ global _patched_stream_sync, _original_generate_content_stream
473
+
474
+ if not _patched_stream_sync or _original_generate_content_stream is None:
475
+ return
476
+
477
+ try:
478
+ from google.genai.models import Models
479
+
480
+ Models.generate_content_stream = _original_generate_content_stream # type: ignore[assignment]
481
+ _patched_stream_sync = False
482
+ _original_generate_content_stream = None
483
+ logger.info("Gemini streaming SDK unpatched")
484
+ except ImportError:
485
+ pass
486
+
487
+
488
+ def unpatch_gemini_async() -> None:
489
+ """Restore original Gemini AsyncModels.generate_content method."""
490
+ global _patched_async, _original_generate_content_async
491
+
492
+ if not _patched_async or _original_generate_content_async is None:
493
+ return
494
+
495
+ try:
496
+ from google.genai.models import AsyncModels
497
+
498
+ AsyncModels.generate_content = _original_generate_content_async # type: ignore[assignment]
499
+ _patched_async = False
500
+ _original_generate_content_async = None
501
+ logger.info("Gemini async SDK unpatched")
502
+ except ImportError:
503
+ pass
504
+
505
+
506
+ def unpatch_gemini_stream_async() -> None:
507
+ """Restore original Gemini AsyncModels.generate_content_stream method."""
508
+ global _patched_stream_async, _original_generate_content_stream_async
509
+
510
+ if not _patched_stream_async or _original_generate_content_stream_async is None:
511
+ return
512
+
513
+ try:
514
+ from google.genai.models import AsyncModels
515
+
516
+ AsyncModels.generate_content_stream = _original_generate_content_stream_async # type: ignore[assignment]
517
+ _patched_stream_async = False
518
+ _original_generate_content_stream_async = None
519
+ logger.info("Gemini async streaming SDK unpatched")
520
+ except ImportError:
521
+ pass
522
+
523
+
524
+ def unpatch_gemini_all() -> None:
525
+ """Restore all original Gemini SDK methods."""
526
+ unpatch_gemini()
527
+ unpatch_gemini_stream()
528
+ unpatch_gemini_async()
529
+ unpatch_gemini_stream_async()