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 +1 -1
- agentprdiff/adapters/openai.py +273 -73
- agentprdiff/cli.py +242 -8
- agentprdiff/filtering.py +125 -0
- agentprdiff/reporters.py +314 -3
- agentprdiff/scaffold.py +528 -0
- {agentprdiff-0.2.0.dist-info → agentprdiff-0.2.2.dist-info}/METADATA +117 -12
- {agentprdiff-0.2.0.dist-info → agentprdiff-0.2.2.dist-info}/RECORD +11 -9
- {agentprdiff-0.2.0.dist-info → agentprdiff-0.2.2.dist-info}/WHEEL +1 -1
- {agentprdiff-0.2.0.dist-info → agentprdiff-0.2.2.dist-info}/entry_points.txt +0 -0
- {agentprdiff-0.2.0.dist-info → agentprdiff-0.2.2.dist-info}/licenses/LICENSE +0 -0
agentprdiff/__init__.py
CHANGED
agentprdiff/adapters/openai.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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"
|
|
520
|
+
_wrapped.__name__ = f"instrumented_async_{name}"
|
|
321
521
|
_wrapped.__doc__ = getattr(fn, "__doc__", None)
|
|
322
522
|
return _wrapped
|
|
323
523
|
|