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,904 @@
1
+ """OpenAI 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_chat_sync = False
13
+ _patched_chat_async = False
14
+ _patched_responses_sync = False
15
+ _patched_responses_async = False
16
+ _patched_images_sync = False
17
+ _patched_images_async = False
18
+ _patched_beta_parse_sync = False
19
+ _patched_beta_parse_async = False
20
+
21
+ # Store original methods for unpatching
22
+ _original_chat_create: Any = None
23
+ _original_chat_create_async: Any = None
24
+ _original_responses_create: Any = None
25
+ _original_responses_create_async: Any = None
26
+ _original_images_generate: Any = None
27
+ _original_images_generate_async: Any = None
28
+ _original_beta_parse: Any = None
29
+ _original_beta_parse_async: Any = None
30
+
31
+ def _is_openai_endpoint(client: Any) -> bool:
32
+ """
33
+ Check if client is actually hitting OpenAI, not other OpenAI-compatible endpoints.
34
+ """
35
+
36
+ base_url = str(getattr(client, "base_url", ""))
37
+ # OpenAI domains
38
+ if "api.openai.com" in base_url:
39
+ return True
40
+ # Azure OpenAI
41
+ if "openai.azure.com" in base_url:
42
+ return True
43
+ if "generativelanguage.googleapis.com" in base_url:
44
+ return False
45
+ # Default to False for custom models that use OpenAI-compatible endpoints
46
+ return False
47
+
48
+
49
+ def _track_chat_completion_response(
50
+ response: Any,
51
+ model: str,
52
+ context: dict[str, Any],
53
+ ) -> None:
54
+ """Extract usage from Chat Completions response and queue transaction with full context."""
55
+ if not hasattr(response, "usage") or response.usage is None:
56
+ return
57
+
58
+ usage = response.usage
59
+
60
+ metrics: dict[str, Any] = {
61
+ "input_tokens": getattr(usage, "prompt_tokens", 0),
62
+ "output_tokens": getattr(usage, "completion_tokens", 0),
63
+ "total_tokens": getattr(usage, "total_tokens", 0),
64
+ }
65
+
66
+ # Include reasoning tokens if present (o1/o3 models)
67
+ if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details:
68
+ details = usage.completion_tokens_details
69
+ if hasattr(details, "reasoning_tokens") and details.reasoning_tokens:
70
+ metrics["reasoning_tokens"] = details.reasoning_tokens
71
+
72
+ # Include cached tokens if present
73
+ if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
74
+ details = usage.prompt_tokens_details
75
+ if hasattr(details, "cached_tokens") and details.cached_tokens:
76
+ metrics["cached_tokens"] = details.cached_tokens
77
+
78
+ transaction = {
79
+ "provider": "openai",
80
+ "model": model,
81
+ "usage": [
82
+ {
83
+ "type": "tokens",
84
+ "metrics": metrics,
85
+ }
86
+ ],
87
+ "context": context, # Full merged context from set_context() calls
88
+ }
89
+
90
+ try:
91
+ transaction["provider_usage_raw"] = {
92
+ "prompt_tokens": getattr(usage, "prompt_tokens", None),
93
+ "completion_tokens": getattr(usage, "completion_tokens", None),
94
+ "total_tokens": getattr(usage, "total_tokens", None),
95
+ }
96
+ except Exception:
97
+ pass
98
+
99
+ enqueue_transaction(transaction)
100
+
101
+
102
+ def _track_responses_api_response(
103
+ response: Any,
104
+ model: str,
105
+ context: dict[str, Any],
106
+ tools: list[dict[str, Any]] | None = None,
107
+ ) -> None:
108
+ """Extract usage from Responses API response and queue transaction with full context."""
109
+ if not hasattr(response, "usage") or response.usage is None:
110
+ return
111
+
112
+ usage = response.usage
113
+
114
+ # Build token metrics (Responses API uses different field names)
115
+ metrics: dict[str, Any] = {
116
+ "input_tokens": getattr(usage, "input_tokens", 0),
117
+ "output_tokens": getattr(usage, "output_tokens", 0),
118
+ "total_tokens": getattr(usage, "total_tokens", 0),
119
+ }
120
+
121
+ # Include reasoning tokens if present (o1/o3 models)
122
+ if hasattr(usage, "output_tokens_details") and usage.output_tokens_details:
123
+ details = usage.output_tokens_details
124
+ if hasattr(details, "reasoning_tokens") and details.reasoning_tokens:
125
+ metrics["reasoning_tokens"] = details.reasoning_tokens
126
+
127
+ # Include cached tokens if present
128
+ if hasattr(usage, "input_tokens_details") and usage.input_tokens_details:
129
+ details = usage.input_tokens_details
130
+ if hasattr(details, "cached_tokens") and details.cached_tokens:
131
+ metrics["cached_tokens"] = details.cached_tokens
132
+
133
+ usage_entries: list[dict[str, Any]] = [
134
+ {
135
+ "type": "tokens",
136
+ "metrics": metrics,
137
+ }
138
+ ]
139
+
140
+ # Add requests usage when web_search tool is used
141
+ if tools and any(t.get("type") in ("web_search", "web_search_preview") for t in tools):
142
+ usage_entries.append(
143
+ {
144
+ "type": "requests",
145
+ "metrics": {"count": 1, "request_type": "web_search"},
146
+ }
147
+ )
148
+
149
+ transaction: dict[str, Any] = {
150
+ "provider": "openai",
151
+ "model": model,
152
+ "usage": usage_entries,
153
+ "context": context,
154
+ }
155
+
156
+ # Include raw usage for debugging if available
157
+ try:
158
+ transaction["provider_usage_raw"] = {
159
+ "input_tokens": getattr(usage, "input_tokens", None),
160
+ "output_tokens": getattr(usage, "output_tokens", None),
161
+ "total_tokens": getattr(usage, "total_tokens", None),
162
+ }
163
+ except Exception:
164
+ pass
165
+
166
+ enqueue_transaction(transaction)
167
+
168
+
169
+ def _track_images_response(
170
+ model: str,
171
+ n: int,
172
+ size: str,
173
+ quality: str,
174
+ context: dict[str, Any],
175
+ response: Any = None,
176
+ ) -> None:
177
+ """Track image generation usage and queue transaction with full context.
178
+
179
+ Prefers quality/size/n from the API response when available (reflects actual
180
+ usage); otherwise uses request kwargs.
181
+ """
182
+ # Prefer response when available (API returns what was actually used)
183
+ if response is not None:
184
+ quality = getattr(response, "quality", None) or quality
185
+ size = getattr(response, "size", None) or size
186
+ if hasattr(response, "data") and response.data is not None:
187
+ n = len(response.data)
188
+
189
+ # Parse size to get pixels (e.g., "1024x1024" -> 1024)
190
+ size_px: int | None = None
191
+ if size and "x" in str(size):
192
+ try:
193
+ size_px = int(str(size).split("x")[0])
194
+ except (ValueError, TypeError):
195
+ pass
196
+
197
+ metrics: dict[str, Any] = {
198
+ "generated": n,
199
+ "size_px": size_px,
200
+ "quality": quality or None,
201
+ }
202
+ # Omit None values for API payload
203
+ metrics = {k: v for k, v in metrics.items() if v is not None}
204
+
205
+ transaction = {
206
+ "provider": "openai",
207
+ "model": model,
208
+ "usage": [
209
+ {
210
+ "type": "images",
211
+ "metrics": metrics,
212
+ }
213
+ ],
214
+ "context": context,
215
+ }
216
+ enqueue_transaction(transaction)
217
+
218
+
219
+ def _track_openai_stream(
220
+ stream: Any,
221
+ model: str,
222
+ context: dict[str, Any],
223
+ is_responses_api: bool = False,
224
+ tools: list[dict[str, Any]] | None = None,
225
+ ) -> Any:
226
+ """
227
+ Wrap a streaming response to track usage from the final chunk.
228
+
229
+ Returns a wrapped stream that tracks usage when the stream is exhausted.
230
+ """
231
+ last_chunk = None
232
+
233
+ def wrapped_stream() -> Any:
234
+ nonlocal last_chunk
235
+ try:
236
+ for chunk in stream:
237
+ last_chunk = chunk
238
+ yield chunk
239
+ finally:
240
+ # After stream is exhausted, track usage from final chunk
241
+ if last_chunk and hasattr(last_chunk, "usage") and last_chunk.usage:
242
+ try:
243
+ if is_responses_api:
244
+ _track_responses_api_response(
245
+ last_chunk, model, context, tools=tools
246
+ )
247
+ else:
248
+ _track_chat_completion_response(last_chunk, model, context)
249
+ except Exception as e:
250
+ logger.error(
251
+ f"Error tracking OpenAI streaming usage: {e}", exc_info=True
252
+ )
253
+
254
+ return wrapped_stream()
255
+
256
+
257
+ async def _track_openai_stream_async(
258
+ stream: Any,
259
+ model: str,
260
+ context: dict[str, Any],
261
+ is_responses_api: bool = False,
262
+ tools: list[dict[str, Any]] | None = None,
263
+ ) -> Any:
264
+ """
265
+ Wrap an async streaming response to track usage from the final chunk.
266
+
267
+ Returns a wrapped async stream that tracks usage when the stream is exhausted.
268
+ """
269
+ last_chunk = None
270
+
271
+ try:
272
+ async for chunk in stream:
273
+ last_chunk = chunk
274
+ yield chunk
275
+ finally:
276
+ # After stream is exhausted, track usage from final chunk
277
+ if last_chunk and hasattr(last_chunk, "usage") and last_chunk.usage:
278
+ try:
279
+ if is_responses_api:
280
+ _track_responses_api_response(
281
+ last_chunk, model, context, tools=tools
282
+ )
283
+ else:
284
+ _track_chat_completion_response(last_chunk, model, context)
285
+ except Exception as e:
286
+ logger.error(
287
+ f"Error tracking OpenAI streaming usage: {e}", exc_info=True
288
+ )
289
+
290
+
291
+ # ============================================================================
292
+ # Chat Completions API Patching
293
+ # ============================================================================
294
+
295
+ def patch_openai() -> None:
296
+ """
297
+ Patch OpenAI Chat Completions client to auto-track usage.
298
+
299
+ This patches the synchronous `Completions.create` method.
300
+ """
301
+ global _patched_chat_sync, _original_chat_create
302
+
303
+ if _patched_chat_sync:
304
+ logger.debug("OpenAI Chat Completions sync already patched, skipping")
305
+ return
306
+
307
+ try:
308
+ from openai.resources.chat.completions import Completions
309
+ except ImportError:
310
+ logger.warning(
311
+ "OpenAI SDK not installed. Install with: pip install 'fenra[openai]'"
312
+ )
313
+ return
314
+
315
+ original_create = Completions.create
316
+ _original_chat_create = original_create
317
+
318
+ @functools.wraps(original_create)
319
+ def patched_create(self: Completions, *args: Any, **kwargs: Any) -> Any:
320
+ if not _is_openai_endpoint(self._client):
321
+ return original_create(self, *args, **kwargs)
322
+
323
+ is_streaming = kwargs.get("stream", False)
324
+
325
+ response = original_create(self, *args, **kwargs)
326
+
327
+ context = get_context()
328
+
329
+ model = kwargs.get("model") or getattr(response, "model", "unknown")
330
+
331
+ try:
332
+ if is_streaming:
333
+ return _track_openai_stream(response, model, context, is_responses_api=False)
334
+ else:
335
+ _track_chat_completion_response(response, model, context)
336
+ except Exception as e:
337
+ logger.error(f"Error tracking OpenAI usage: {e}", exc_info=True)
338
+
339
+ return response
340
+
341
+ Completions.create = patched_create # type: ignore[assignment]
342
+ _patched_chat_sync = True
343
+ logger.info("OpenAI Chat Completions SDK patched for auto-instrumentation")
344
+
345
+
346
+ def patch_openai_async() -> None:
347
+ """
348
+ Patch OpenAI Chat Completions async client to auto-track usage.
349
+
350
+ This patches the asynchronous `AsyncCompletions.create` method.
351
+ """
352
+ global _patched_chat_async, _original_chat_create_async
353
+
354
+ if _patched_chat_async:
355
+ logger.debug("OpenAI Chat Completions async already patched, skipping")
356
+ return
357
+
358
+ try:
359
+ from openai.resources.chat.completions import AsyncCompletions
360
+ except ImportError:
361
+ logger.warning(
362
+ "OpenAI SDK not installed. Install with: pip install 'fenra[openai]'"
363
+ )
364
+ return
365
+
366
+ original_create = AsyncCompletions.create
367
+ _original_chat_create_async = original_create
368
+
369
+ @functools.wraps(original_create)
370
+ async def patched_create(
371
+ self: AsyncCompletions, *args: Any, **kwargs: Any
372
+ ) -> Any:
373
+ if not _is_openai_endpoint(self._client):
374
+ return await original_create(self, *args, **kwargs)
375
+
376
+ is_streaming = kwargs.get("stream", False)
377
+
378
+ response = await original_create(self, *args, **kwargs)
379
+
380
+ context = get_context()
381
+
382
+ model = kwargs.get("model") or getattr(response, "model", "unknown")
383
+
384
+ try:
385
+ if is_streaming:
386
+ return _track_openai_stream_async(response, model, context, is_responses_api=False)
387
+ else:
388
+ _track_chat_completion_response(response, model, context)
389
+ except Exception as e:
390
+ logger.error(f"Error tracking OpenAI usage: {e}", exc_info=True)
391
+
392
+ return response
393
+
394
+ AsyncCompletions.create = patched_create # type: ignore[assignment]
395
+ _patched_chat_async = True
396
+ logger.info("OpenAI Chat Completions async SDK patched for auto-instrumentation")
397
+
398
+
399
+ # ============================================================================
400
+ # Responses API Patching
401
+ # ============================================================================
402
+
403
+
404
+ def patch_openai_responses() -> None:
405
+ """
406
+ Patch OpenAI Responses API client to auto-track usage.
407
+
408
+ This patches the synchronous `Responses.create` method.
409
+ """
410
+ global _patched_responses_sync, _original_responses_create
411
+
412
+ if _patched_responses_sync:
413
+ logger.debug("OpenAI Responses API sync already patched, skipping")
414
+ return
415
+
416
+ try:
417
+ from openai.resources.responses import Responses
418
+ except ImportError:
419
+ logger.warning(
420
+ "OpenAI SDK not installed. Install with: pip install 'fenra[openai]'"
421
+ )
422
+ return
423
+
424
+ original_create = Responses.create
425
+ _original_responses_create = original_create
426
+
427
+ @functools.wraps(original_create)
428
+ def patched_create(self: Responses, *args: Any, **kwargs: Any) -> Any:
429
+ if not _is_openai_endpoint(self._client):
430
+ return original_create(self, *args, **kwargs)
431
+
432
+ is_streaming = kwargs.get("stream", False)
433
+
434
+ response = original_create(self, *args, **kwargs)
435
+
436
+ context = get_context()
437
+
438
+ model = kwargs.get("model") or getattr(response, "model", "unknown")
439
+
440
+ tools = kwargs.get("tools")
441
+ try:
442
+ if is_streaming:
443
+ return _track_openai_stream(
444
+ response, model, context, is_responses_api=True, tools=tools
445
+ )
446
+ else:
447
+ _track_responses_api_response(response, model, context, tools=tools)
448
+ except Exception as e:
449
+ logger.error(f"Error tracking OpenAI Responses API usage: {e}", exc_info=True)
450
+
451
+ return response
452
+
453
+ Responses.create = patched_create # type: ignore[assignment]
454
+ _patched_responses_sync = True
455
+ logger.info("OpenAI Responses API SDK patched for auto-instrumentation")
456
+
457
+
458
+ def patch_openai_responses_async() -> None:
459
+ """
460
+ Patch OpenAI Responses API async client to auto-track usage.
461
+
462
+ This patches the asynchronous `AsyncResponses.create` method.
463
+ """
464
+ global _patched_responses_async, _original_responses_create_async
465
+
466
+ if _patched_responses_async:
467
+ logger.debug("OpenAI Responses API async already patched, skipping")
468
+ return
469
+
470
+ try:
471
+ from openai.resources.responses import AsyncResponses
472
+ except ImportError:
473
+ logger.warning(
474
+ "OpenAI SDK not installed. Install with: pip install 'fenra[openai]'"
475
+ )
476
+ return
477
+
478
+ original_create = AsyncResponses.create
479
+ _original_responses_create_async = original_create
480
+
481
+ @functools.wraps(original_create)
482
+ async def patched_create(
483
+ self: AsyncResponses, *args: Any, **kwargs: Any
484
+ ) -> Any:
485
+ if not _is_openai_endpoint(self._client):
486
+ return await original_create(self, *args, **kwargs)
487
+
488
+ is_streaming = kwargs.get("stream", False)
489
+
490
+ response = await original_create(self, *args, **kwargs)
491
+
492
+ context = get_context()
493
+
494
+ model = kwargs.get("model") or getattr(response, "model", "unknown")
495
+
496
+ tools = kwargs.get("tools")
497
+ try:
498
+ if is_streaming:
499
+ return _track_openai_stream_async(
500
+ response, model, context, is_responses_api=True, tools=tools
501
+ )
502
+ else:
503
+ _track_responses_api_response(response, model, context, tools=tools)
504
+ except Exception as e:
505
+ logger.error(f"Error tracking OpenAI Responses API usage: {e}", exc_info=True)
506
+
507
+ return response
508
+
509
+ AsyncResponses.create = patched_create # type: ignore[assignment]
510
+ _patched_responses_async = True
511
+ logger.info("OpenAI Responses API async SDK patched for auto-instrumentation")
512
+
513
+
514
+ # ============================================================================
515
+ # Images API Patching
516
+ # ============================================================================
517
+
518
+
519
+ def patch_openai_images() -> None:
520
+ """
521
+ Patch OpenAI Images client to auto-track usage.
522
+
523
+ This patches the synchronous `Images.generate` method.
524
+ """
525
+ global _patched_images_sync, _original_images_generate
526
+
527
+ if _patched_images_sync:
528
+ logger.debug("OpenAI Images sync already patched, skipping")
529
+ return
530
+
531
+ try:
532
+ from openai.resources.images import Images
533
+ except ImportError:
534
+ logger.warning(
535
+ "OpenAI SDK not installed. Install with: pip install 'fenra[openai]'"
536
+ )
537
+ return
538
+
539
+ original_generate = Images.generate
540
+ _original_images_generate = original_generate
541
+
542
+ @functools.wraps(original_generate)
543
+ def patched_generate(self: Any, *args: Any, **kwargs: Any) -> Any:
544
+ if not _is_openai_endpoint(self._client):
545
+ return original_generate(self, *args, **kwargs)
546
+
547
+ response = original_generate(self, *args, **kwargs)
548
+
549
+ context = get_context()
550
+ model = kwargs.get("model", "unknown")
551
+ n = kwargs.get("n", 1)
552
+ size = kwargs.get("size", "")
553
+ quality = kwargs.get("quality", "")
554
+
555
+ try:
556
+ _track_images_response(
557
+ model=model,
558
+ n=n,
559
+ size=size,
560
+ quality=quality,
561
+ context=context,
562
+ response=response,
563
+ )
564
+ except Exception as e:
565
+ logger.error(f"Error tracking OpenAI Images usage: {e}", exc_info=True)
566
+
567
+ return response
568
+
569
+ Images.generate = patched_generate # type: ignore[assignment]
570
+ _patched_images_sync = True
571
+ logger.info("OpenAI Images SDK patched for auto-instrumentation")
572
+
573
+
574
+ def patch_openai_images_async() -> None:
575
+ """
576
+ Patch OpenAI Images async client to auto-track usage.
577
+
578
+ This patches the asynchronous `AsyncImages.generate` method.
579
+ """
580
+ global _patched_images_async, _original_images_generate_async
581
+
582
+ if _patched_images_async:
583
+ logger.debug("OpenAI Images async already patched, skipping")
584
+ return
585
+
586
+ try:
587
+ from openai.resources.images import AsyncImages
588
+ except ImportError:
589
+ logger.warning(
590
+ "OpenAI SDK not installed. Install with: pip install 'fenra[openai]'"
591
+ )
592
+ return
593
+
594
+ original_generate = AsyncImages.generate
595
+ _original_images_generate_async = original_generate
596
+
597
+ @functools.wraps(original_generate)
598
+ async def patched_generate(self: Any, *args: Any, **kwargs: Any) -> Any:
599
+ if not _is_openai_endpoint(self._client):
600
+ return await original_generate(self, *args, **kwargs)
601
+
602
+ response = await original_generate(self, *args, **kwargs)
603
+
604
+ context = get_context()
605
+ model = kwargs.get("model", "unknown")
606
+ n = kwargs.get("n", 1)
607
+ size = kwargs.get("size", "")
608
+ quality = kwargs.get("quality", "")
609
+
610
+ try:
611
+ _track_images_response(
612
+ model=model,
613
+ n=n,
614
+ size=size,
615
+ quality=quality,
616
+ context=context,
617
+ response=response,
618
+ )
619
+ except Exception as e:
620
+ logger.error(f"Error tracking OpenAI Images usage: {e}", exc_info=True)
621
+
622
+ return response
623
+
624
+ AsyncImages.generate = patched_generate # type: ignore[assignment]
625
+ _patched_images_async = True
626
+ logger.info("OpenAI Images async SDK patched for auto-instrumentation")
627
+
628
+
629
+ # ============================================================================
630
+ # Beta Chat Completions Parse Patching
631
+ # ============================================================================
632
+
633
+
634
+ def patch_openai_beta_parse() -> None:
635
+ """
636
+ Patch OpenAI Beta Chat Completions parse to auto-track usage.
637
+
638
+ This patches the synchronous `beta.chat.completions.parse` method.
639
+ Response format is identical to Chat Completions, so we reuse
640
+ _track_chat_completion_response.
641
+ """
642
+ global _patched_beta_parse_sync, _original_beta_parse
643
+
644
+ if _patched_beta_parse_sync:
645
+ logger.debug("OpenAI Beta parse sync already patched, skipping")
646
+ return
647
+
648
+ try:
649
+ from openai.resources.beta.chat.completions import Completions as BetaCompletions
650
+ except ImportError:
651
+ logger.debug(
652
+ "OpenAI Beta chat completions not available (optional). "
653
+ "Skip patching beta parse."
654
+ )
655
+ return
656
+
657
+ if not hasattr(BetaCompletions, "parse"):
658
+ logger.debug("OpenAI Beta Completions has no parse method, skipping")
659
+ return
660
+
661
+ original_parse = BetaCompletions.parse
662
+ _original_beta_parse = original_parse
663
+
664
+ @functools.wraps(original_parse)
665
+ def patched_parse(self: Any, *args: Any, **kwargs: Any) -> Any:
666
+ if not _is_openai_endpoint(self._client):
667
+ return original_parse(self, *args, **kwargs)
668
+
669
+ response = original_parse(self, *args, **kwargs)
670
+
671
+ context = get_context()
672
+ model = kwargs.get("model") or getattr(response, "model", "unknown")
673
+
674
+ try:
675
+ _track_chat_completion_response(response, model, context)
676
+ except Exception as e:
677
+ logger.error(
678
+ f"Error tracking OpenAI Beta parse usage: {e}", exc_info=True
679
+ )
680
+
681
+ return response
682
+
683
+ BetaCompletions.parse = patched_parse # type: ignore[assignment]
684
+ _patched_beta_parse_sync = True
685
+ logger.info("OpenAI Beta chat.completions.parse SDK patched for auto-instrumentation")
686
+
687
+
688
+ def patch_openai_beta_parse_async() -> None:
689
+ """
690
+ Patch OpenAI Beta Chat Completions async parse to auto-track usage.
691
+
692
+ This patches the asynchronous `beta.chat.completions.parse` method.
693
+ """
694
+ global _patched_beta_parse_async, _original_beta_parse_async
695
+
696
+ if _patched_beta_parse_async:
697
+ logger.debug("OpenAI Beta parse async already patched, skipping")
698
+ return
699
+
700
+ try:
701
+ from openai.resources.beta.chat.completions import (
702
+ AsyncCompletions as AsyncBetaCompletions,
703
+ )
704
+ except ImportError:
705
+ logger.debug(
706
+ "OpenAI Beta async chat completions not available (optional). "
707
+ "Skip patching beta parse async."
708
+ )
709
+ return
710
+
711
+ if not hasattr(AsyncBetaCompletions, "parse"):
712
+ logger.debug("OpenAI AsyncBeta Completions has no parse method, skipping")
713
+ return
714
+
715
+ original_parse = AsyncBetaCompletions.parse
716
+ _original_beta_parse_async = original_parse
717
+
718
+ @functools.wraps(original_parse)
719
+ async def patched_parse(self: Any, *args: Any, **kwargs: Any) -> Any:
720
+ if not _is_openai_endpoint(self._client):
721
+ return await original_parse(self, *args, **kwargs)
722
+
723
+ response = await original_parse(self, *args, **kwargs)
724
+
725
+ context = get_context()
726
+ model = kwargs.get("model") or getattr(response, "model", "unknown")
727
+
728
+ try:
729
+ _track_chat_completion_response(response, model, context)
730
+ except Exception as e:
731
+ logger.error(
732
+ f"Error tracking OpenAI Beta parse usage: {e}", exc_info=True
733
+ )
734
+
735
+ return response
736
+
737
+ AsyncBetaCompletions.parse = patched_parse # type: ignore[assignment]
738
+ _patched_beta_parse_async = True
739
+ logger.info(
740
+ "OpenAI Beta chat.completions.parse async SDK patched for auto-instrumentation"
741
+ )
742
+
743
+
744
+ # ============================================================================
745
+ # Unpatch Functions
746
+ # ============================================================================
747
+
748
+
749
+ def unpatch_openai() -> None:
750
+ """Restore original OpenAI Chat Completions create method."""
751
+ global _patched_chat_sync, _original_chat_create
752
+
753
+ if not _patched_chat_sync or _original_chat_create is None:
754
+ return
755
+
756
+ try:
757
+ from openai.resources.chat.completions import Completions
758
+
759
+ Completions.create = _original_chat_create # type: ignore[assignment]
760
+ _patched_chat_sync = False
761
+ _original_chat_create = None
762
+ logger.info("OpenAI Chat Completions SDK unpatched")
763
+ except ImportError:
764
+ pass
765
+
766
+
767
+ def unpatch_openai_async() -> None:
768
+ """Restore original OpenAI Chat Completions async create method."""
769
+ global _patched_chat_async, _original_chat_create_async
770
+
771
+ if not _patched_chat_async or _original_chat_create_async is None:
772
+ return
773
+
774
+ try:
775
+ from openai.resources.chat.completions import AsyncCompletions
776
+
777
+ AsyncCompletions.create = _original_chat_create_async # type: ignore[assignment]
778
+ _patched_chat_async = False
779
+ _original_chat_create_async = None
780
+ logger.info("OpenAI Chat Completions async SDK unpatched")
781
+ except ImportError:
782
+ pass
783
+
784
+
785
+ def unpatch_openai_responses() -> None:
786
+ """Restore original OpenAI Responses API create method."""
787
+ global _patched_responses_sync, _original_responses_create
788
+
789
+ if not _patched_responses_sync or _original_responses_create is None:
790
+ return
791
+
792
+ try:
793
+ from openai.resources.responses import Responses
794
+
795
+ Responses.create = _original_responses_create # type: ignore[assignment]
796
+ _patched_responses_sync = False
797
+ _original_responses_create = None
798
+ logger.info("OpenAI Responses API SDK unpatched")
799
+ except ImportError:
800
+ pass
801
+
802
+
803
+ def unpatch_openai_responses_async() -> None:
804
+ """Restore original OpenAI Responses API async create method."""
805
+ global _patched_responses_async, _original_responses_create_async
806
+
807
+ if not _patched_responses_async or _original_responses_create_async is None:
808
+ return
809
+
810
+ try:
811
+ from openai.resources.responses import AsyncResponses
812
+
813
+ AsyncResponses.create = _original_responses_create_async # type: ignore[assignment]
814
+ _patched_responses_async = False
815
+ _original_responses_create_async = None
816
+ logger.info("OpenAI Responses API async SDK unpatched")
817
+ except ImportError:
818
+ pass
819
+
820
+
821
+ def unpatch_openai_images() -> None:
822
+ """Restore original OpenAI Images generate method."""
823
+ global _patched_images_sync, _original_images_generate
824
+
825
+ if not _patched_images_sync or _original_images_generate is None:
826
+ return
827
+
828
+ try:
829
+ from openai.resources.images import Images
830
+
831
+ Images.generate = _original_images_generate # type: ignore[assignment]
832
+ _patched_images_sync = False
833
+ _original_images_generate = None
834
+ logger.info("OpenAI Images SDK unpatched")
835
+ except ImportError:
836
+ pass
837
+
838
+
839
+ def unpatch_openai_images_async() -> None:
840
+ """Restore original OpenAI Images async generate method."""
841
+ global _patched_images_async, _original_images_generate_async
842
+
843
+ if not _patched_images_async or _original_images_generate_async is None:
844
+ return
845
+
846
+ try:
847
+ from openai.resources.images import AsyncImages
848
+
849
+ AsyncImages.generate = _original_images_generate_async # type: ignore[assignment]
850
+ _patched_images_async = False
851
+ _original_images_generate_async = None
852
+ logger.info("OpenAI Images async SDK unpatched")
853
+ except ImportError:
854
+ pass
855
+
856
+
857
+ def unpatch_openai_beta_parse() -> None:
858
+ """Restore original OpenAI Beta Chat Completions parse method."""
859
+ global _patched_beta_parse_sync, _original_beta_parse
860
+
861
+ if not _patched_beta_parse_sync or _original_beta_parse is None:
862
+ return
863
+
864
+ try:
865
+ from openai.resources.beta.chat.completions import Completions as BetaCompletions
866
+
867
+ BetaCompletions.parse = _original_beta_parse # type: ignore[assignment]
868
+ _patched_beta_parse_sync = False
869
+ _original_beta_parse = None
870
+ logger.info("OpenAI Beta chat.completions.parse SDK unpatched")
871
+ except ImportError:
872
+ pass
873
+
874
+
875
+ def unpatch_openai_beta_parse_async() -> None:
876
+ """Restore original OpenAI Beta Chat Completions async parse method."""
877
+ global _patched_beta_parse_async, _original_beta_parse_async
878
+
879
+ if not _patched_beta_parse_async or _original_beta_parse_async is None:
880
+ return
881
+
882
+ try:
883
+ from openai.resources.beta.chat.completions import (
884
+ AsyncCompletions as AsyncBetaCompletions,
885
+ )
886
+
887
+ AsyncBetaCompletions.parse = _original_beta_parse_async # type: ignore[assignment]
888
+ _patched_beta_parse_async = False
889
+ _original_beta_parse_async = None
890
+ logger.info("OpenAI Beta chat.completions.parse async SDK unpatched")
891
+ except ImportError:
892
+ pass
893
+
894
+
895
+ def unpatch_openai_all() -> None:
896
+ """Restore all original OpenAI SDK methods."""
897
+ unpatch_openai()
898
+ unpatch_openai_async()
899
+ unpatch_openai_responses()
900
+ unpatch_openai_responses_async()
901
+ unpatch_openai_images()
902
+ unpatch_openai_images_async()
903
+ unpatch_openai_beta_parse()
904
+ unpatch_openai_beta_parse_async()