agentprdiff 0.2.0__py3-none-any.whl → 0.2.2__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.
agentprdiff/__init__.py CHANGED
@@ -67,7 +67,7 @@ from .differ import AssertionChange, TraceDelta, diff_traces
67
67
  from .runner import CaseReport, Runner, RunReport
68
68
  from .store import BaselineStore
69
69
 
70
- __version__ = "0.2.0"
70
+ __version__ = "0.2.2"
71
71
 
72
72
  __all__ = [
73
73
  # core
@@ -7,14 +7,14 @@ outside the ``with`` block.
7
7
 
8
8
  This adapter works with any SDK that uses the OpenAI Python client shape:
9
9
 
10
- * OpenAI itself
10
+ * OpenAI / **AsyncOpenAI** itself
11
11
  * Groq (``base_url="https://api.groq.com/openai/v1"``)
12
12
  * Google Gemini's OpenAI-compatible endpoint
13
13
  * OpenRouter
14
14
  * Ollama (``base_url="http://localhost:11434/v1"``)
15
15
  * vLLM, Together, Fireworks, DeepInfra, Anyscale, etc.
16
16
 
17
- Usage::
17
+ Sync usage::
18
18
 
19
19
  from agentprdiff.adapters.openai import instrument_client, instrument_tools
20
20
 
@@ -25,6 +25,28 @@ Usage::
25
25
  # ...standard OpenAI tool-calling loop, unchanged...
26
26
  return final_text, trace
27
27
 
28
+ Async usage — same API. ``instrument_client`` detects an ``AsyncOpenAI``
29
+ client (``client.chat.completions.create`` is a coroutine function) and
30
+ installs an awaitable patched method automatically. The ``with`` block
31
+ itself stays a plain ``with`` — the patch is bound to the client instance,
32
+ not the running event loop::
33
+
34
+ from openai import AsyncOpenAI
35
+ from agentprdiff.adapters.openai import instrument_client, instrument_tools
36
+
37
+ async def my_agent_async(query: str):
38
+ client = AsyncOpenAI(api_key=...)
39
+ with instrument_client(client) as trace:
40
+ tools = instrument_tools(TOOL_MAP, trace)
41
+ response = await client.chat.completions.create(...)
42
+ # ...async tool-calling loop, unchanged; tool wrappers are
43
+ # awaitable iff the underlying tool is `async def`...
44
+ return final_text, trace
45
+
46
+ def my_agent(query: str):
47
+ # agentprdiff's runner is sync — bridge with asyncio.run.
48
+ return asyncio.run(my_agent_async(query))
49
+
28
50
  The Trace's ``suite_name`` / ``case_name`` / ``input`` are filled in by
29
51
  ``run_agent`` after the agent returns; you can leave them blank inside the
30
52
  adapter.
@@ -32,8 +54,9 @@ adapter.
32
54
 
33
55
  from __future__ import annotations
34
56
 
57
+ import asyncio
35
58
  import time
36
- from collections.abc import Callable, Iterator, Mapping
59
+ from collections.abc import Awaitable, Callable, Iterator, Mapping
37
60
  from contextlib import contextmanager, suppress
38
61
  from typing import Any
39
62
 
@@ -187,59 +210,18 @@ def instrument_client(
187
210
  had_instance_attr = "create" in vars(completions)
188
211
  instance_attr_value = vars(completions).get("create")
189
212
 
190
- def patched_create(*args: Any, **kwargs: Any) -> Any:
191
- start = time.perf_counter()
192
- try:
193
- response = original_create(*args, **kwargs)
194
- except Exception as exc: # noqa: BLE001
195
- elapsed_ms = (time.perf_counter() - start) * 1000.0
196
- # Record a failed LLMCall so the trace shows what happened.
197
- trace.record_llm_call(
198
- LLMCall(
199
- provider=provider_str,
200
- model=str(kwargs.get("model", "")),
201
- input_messages=_serialize_messages(kwargs.get("messages")),
202
- output_text=f"<exception: {type(exc).__name__}: {exc}>",
203
- latency_ms=elapsed_ms,
204
- )
205
- )
206
- raise
207
-
208
- elapsed_ms = (time.perf_counter() - start) * 1000.0
209
-
210
- # Pull usage / model / output safely; some compatible servers omit
211
- # fields we'd like to have.
212
- usage = getattr(response, "usage", None)
213
- prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
214
- completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
215
- model_id = str(getattr(response, "model", "") or kwargs.get("model", "") or "")
216
-
217
- choices = getattr(response, "choices", None) or []
218
- message = getattr(choices[0], "message", None) if choices else None
219
- output_text = getattr(message, "content", None) or "" if message is not None else ""
220
- tool_calls_summary = _extract_tool_calls(message) if message is not None else []
221
-
222
- cost = estimate_cost_usd(
223
- model_id,
224
- prompt_tokens=prompt_tokens,
225
- completion_tokens=completion_tokens,
226
- prices=prices,
213
+ # AsyncOpenAI's `create` is `async def`; the sync OpenAI's is plain. Pick
214
+ # the right shape so callers get an awaitable iff the original was one.
215
+ is_async = asyncio.iscoroutinefunction(original_create)
216
+ patched_create = (
217
+ _make_async_patched_create(
218
+ original_create, trace=trace, provider=provider_str, prices=prices
227
219
  )
228
-
229
- trace.record_llm_call(
230
- LLMCall(
231
- provider=provider_str,
232
- model=model_id,
233
- input_messages=_serialize_messages(kwargs.get("messages")),
234
- output_text=output_text,
235
- tool_calls=tool_calls_summary,
236
- prompt_tokens=prompt_tokens,
237
- completion_tokens=completion_tokens,
238
- cost_usd=cost,
239
- latency_ms=elapsed_ms,
240
- )
220
+ if is_async
221
+ else _make_sync_patched_create(
222
+ original_create, trace=trace, provider=provider_str, prices=prices
241
223
  )
242
- return response
224
+ )
243
225
 
244
226
  # Apply the patch on this specific instance only.
245
227
  completions.create = patched_create # type: ignore[method-assign]
@@ -256,6 +238,141 @@ def instrument_client(
256
238
  del completions.create # type: ignore[attr-defined]
257
239
 
258
240
 
241
+ # ---------------------------------------------------------------------------
242
+ # Patched-create factories.
243
+ #
244
+ # Sync and async paths share everything except the call-and-await step. We
245
+ # factor the shared response-parsing into _record_completion so the timing
246
+ # code stays a thin wrapper above it. Avoids drift between the two paths.
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ def _record_completion(
251
+ response: Any,
252
+ *,
253
+ trace: Trace,
254
+ kwargs: Mapping[str, Any],
255
+ elapsed_ms: float,
256
+ provider: str,
257
+ prices: PriceTable | None,
258
+ ) -> None:
259
+ """Extract usage/output/tool-calls from an OpenAI-shaped response and
260
+ record an ``LLMCall`` onto ``trace``. Shared by sync and async patches."""
261
+ # Pull usage / model / output safely; some compatible servers omit fields
262
+ # we'd like to have.
263
+ usage = getattr(response, "usage", None)
264
+ prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
265
+ completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
266
+ model_id = str(getattr(response, "model", "") or kwargs.get("model", "") or "")
267
+
268
+ choices = getattr(response, "choices", None) or []
269
+ message = getattr(choices[0], "message", None) if choices else None
270
+ output_text = getattr(message, "content", None) or "" if message is not None else ""
271
+ tool_calls_summary = _extract_tool_calls(message) if message is not None else []
272
+
273
+ cost = estimate_cost_usd(
274
+ model_id,
275
+ prompt_tokens=prompt_tokens,
276
+ completion_tokens=completion_tokens,
277
+ prices=prices,
278
+ )
279
+
280
+ trace.record_llm_call(
281
+ LLMCall(
282
+ provider=provider,
283
+ model=model_id,
284
+ input_messages=_serialize_messages(kwargs.get("messages")),
285
+ output_text=output_text,
286
+ tool_calls=tool_calls_summary,
287
+ prompt_tokens=prompt_tokens,
288
+ completion_tokens=completion_tokens,
289
+ cost_usd=cost,
290
+ latency_ms=elapsed_ms,
291
+ )
292
+ )
293
+
294
+
295
+ def _record_failure(
296
+ exc: BaseException,
297
+ *,
298
+ trace: Trace,
299
+ kwargs: Mapping[str, Any],
300
+ elapsed_ms: float,
301
+ provider: str,
302
+ ) -> None:
303
+ trace.record_llm_call(
304
+ LLMCall(
305
+ provider=provider,
306
+ model=str(kwargs.get("model", "")),
307
+ input_messages=_serialize_messages(kwargs.get("messages")),
308
+ output_text=f"<exception: {type(exc).__name__}: {exc}>",
309
+ latency_ms=elapsed_ms,
310
+ )
311
+ )
312
+
313
+
314
+ def _make_sync_patched_create(
315
+ original_create: Callable[..., Any],
316
+ *,
317
+ trace: Trace,
318
+ provider: str,
319
+ prices: PriceTable | None,
320
+ ) -> Callable[..., Any]:
321
+ def patched_create(*args: Any, **kwargs: Any) -> Any:
322
+ start = time.perf_counter()
323
+ try:
324
+ response = original_create(*args, **kwargs)
325
+ except Exception as exc: # noqa: BLE001
326
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
327
+ _record_failure(
328
+ exc, trace=trace, kwargs=kwargs, elapsed_ms=elapsed_ms, provider=provider
329
+ )
330
+ raise
331
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
332
+ _record_completion(
333
+ response,
334
+ trace=trace,
335
+ kwargs=kwargs,
336
+ elapsed_ms=elapsed_ms,
337
+ provider=provider,
338
+ prices=prices,
339
+ )
340
+ return response
341
+
342
+ return patched_create
343
+
344
+
345
+ def _make_async_patched_create(
346
+ original_create: Callable[..., Awaitable[Any]],
347
+ *,
348
+ trace: Trace,
349
+ provider: str,
350
+ prices: PriceTable | None,
351
+ ) -> Callable[..., Awaitable[Any]]:
352
+ async def patched_create(*args: Any, **kwargs: Any) -> Any:
353
+ start = time.perf_counter()
354
+ try:
355
+ response = await original_create(*args, **kwargs)
356
+ except Exception as exc: # noqa: BLE001
357
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
358
+ _record_failure(
359
+ exc, trace=trace, kwargs=kwargs, elapsed_ms=elapsed_ms, provider=provider
360
+ )
361
+ raise
362
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
363
+ _record_completion(
364
+ response,
365
+ trace=trace,
366
+ kwargs=kwargs,
367
+ elapsed_ms=elapsed_ms,
368
+ provider=provider,
369
+ prices=prices,
370
+ )
371
+ return response
372
+
373
+ return patched_create
374
+
375
+
259
376
  def instrument_tools(
260
377
  tool_map: Mapping[str, Callable[..., Any]],
261
378
  trace: Trace,
@@ -268,20 +385,73 @@ def instrument_tools(
268
385
  tools = instrument_tools(TOOL_MAP, trace)
269
386
  result = tools[fn_name](**fn_args)
270
387
 
271
- Each call records:
388
+ For async tools (``async def``), the wrapper is itself ``async def``, so
389
+ awaiting it works exactly like awaiting the original::
390
+
391
+ result = await tools[fn_name](**fn_args)
392
+
393
+ The choice is made per-tool — a tool map can mix sync and async entries
394
+ and each wrapper matches the underlying callable. Each call records:
272
395
 
273
396
  * ``name`` — the dict key
274
397
  * ``arguments`` — the kwargs (and positional args under ``"_args"`` if any)
275
398
  * ``result`` — the return value (or None if the call raised)
276
- * ``latency_ms`` — wall-clock latency
399
+ * ``latency_ms`` — wall-clock latency (await time included for async)
277
400
  * ``error`` — exception text on failure
278
401
  """
279
402
  wrapped: dict[str, Callable[..., Any]] = {}
280
403
  for name, fn in tool_map.items():
281
- wrapped[name] = _make_tool_wrapper(name, fn, trace)
404
+ if asyncio.iscoroutinefunction(fn):
405
+ wrapped[name] = _make_async_tool_wrapper(name, fn, trace)
406
+ else:
407
+ wrapped[name] = _make_tool_wrapper(name, fn, trace)
282
408
  return wrapped
283
409
 
284
410
 
411
+ def _record_tool_success(
412
+ *,
413
+ trace: Trace,
414
+ name: str,
415
+ arguments: dict[str, Any],
416
+ result: Any,
417
+ elapsed_ms: float,
418
+ ) -> None:
419
+ trace.record_tool_call(
420
+ ToolCall(
421
+ name=name,
422
+ arguments=arguments,
423
+ result=_jsonable(result),
424
+ latency_ms=elapsed_ms,
425
+ )
426
+ )
427
+
428
+
429
+ def _record_tool_failure(
430
+ *,
431
+ trace: Trace,
432
+ name: str,
433
+ arguments: dict[str, Any],
434
+ exc: BaseException,
435
+ elapsed_ms: float,
436
+ ) -> None:
437
+ trace.record_tool_call(
438
+ ToolCall(
439
+ name=name,
440
+ arguments=arguments,
441
+ result=None,
442
+ latency_ms=elapsed_ms,
443
+ error=f"{type(exc).__name__}: {exc}",
444
+ )
445
+ )
446
+
447
+
448
+ def _arguments_dict(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
449
+ arguments: dict[str, Any] = dict(kwargs)
450
+ if args:
451
+ arguments["_args"] = list(args)
452
+ return arguments
453
+
454
+
285
455
  def _make_tool_wrapper(
286
456
  name: str,
287
457
  fn: Callable[..., Any],
@@ -289,35 +459,65 @@ def _make_tool_wrapper(
289
459
  ) -> Callable[..., Any]:
290
460
  def _wrapped(*args: Any, **kwargs: Any) -> Any:
291
461
  start = time.perf_counter()
292
- arguments: dict[str, Any] = dict(kwargs)
293
- if args:
294
- arguments["_args"] = list(args)
462
+ arguments = _arguments_dict(args, kwargs)
295
463
  try:
296
464
  result = fn(*args, **kwargs)
297
465
  except Exception as exc: # noqa: BLE001
298
466
  elapsed_ms = (time.perf_counter() - start) * 1000.0
299
- trace.record_tool_call(
300
- ToolCall(
301
- name=name,
302
- arguments=arguments,
303
- result=None,
304
- latency_ms=elapsed_ms,
305
- error=f"{type(exc).__name__}: {exc}",
306
- )
467
+ _record_tool_failure(
468
+ trace=trace,
469
+ name=name,
470
+ arguments=arguments,
471
+ exc=exc,
472
+ elapsed_ms=elapsed_ms,
307
473
  )
308
474
  raise
309
475
  elapsed_ms = (time.perf_counter() - start) * 1000.0
310
- trace.record_tool_call(
311
- ToolCall(
476
+ _record_tool_success(
477
+ trace=trace,
478
+ name=name,
479
+ arguments=arguments,
480
+ result=result,
481
+ elapsed_ms=elapsed_ms,
482
+ )
483
+ return result
484
+
485
+ _wrapped.__name__ = f"instrumented_{name}"
486
+ _wrapped.__doc__ = getattr(fn, "__doc__", None)
487
+ return _wrapped
488
+
489
+
490
+ def _make_async_tool_wrapper(
491
+ name: str,
492
+ fn: Callable[..., Awaitable[Any]],
493
+ trace: Trace,
494
+ ) -> Callable[..., Awaitable[Any]]:
495
+ async def _wrapped(*args: Any, **kwargs: Any) -> Any:
496
+ start = time.perf_counter()
497
+ arguments = _arguments_dict(args, kwargs)
498
+ try:
499
+ result = await fn(*args, **kwargs)
500
+ except Exception as exc: # noqa: BLE001
501
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
502
+ _record_tool_failure(
503
+ trace=trace,
312
504
  name=name,
313
505
  arguments=arguments,
314
- result=_jsonable(result),
315
- latency_ms=elapsed_ms,
506
+ exc=exc,
507
+ elapsed_ms=elapsed_ms,
316
508
  )
509
+ raise
510
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
511
+ _record_tool_success(
512
+ trace=trace,
513
+ name=name,
514
+ arguments=arguments,
515
+ result=result,
516
+ elapsed_ms=elapsed_ms,
317
517
  )
318
518
  return result
319
519
 
320
- _wrapped.__name__ = f"instrumented_{name}"
520
+ _wrapped.__name__ = f"instrumented_async_{name}"
321
521
  _wrapped.__doc__ = getattr(fn, "__doc__", None)
322
522
  return _wrapped
323
523