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.
- fenra/__init__.py +187 -0
- fenra/_context.py +42 -0
- fenra/_core.py +229 -0
- fenra/integrations/__init__.py +1 -0
- fenra/integrations/anthropic/__init__.py +677 -0
- fenra/integrations/gemini/__init__.py +529 -0
- fenra/integrations/openai/__init__.py +904 -0
- fenra/py.typed +0 -0
- fenra-0.1.0.dist-info/METADATA +90 -0
- fenra-0.1.0.dist-info/RECORD +11 -0
- fenra-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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()
|