agentprdiff 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → agentprdiff-0.2.0}/.gitignore +4 -4
- agentprdiff-0.2.0/CHANGELOG.md +103 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/PKG-INFO +32 -2
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/README.md +31 -1
- agentprdiff-0.2.0/examples/regression-tour/README.md +140 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/pyproject.toml +1 -1
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/__init__.py +17 -1
- agentprdiff-0.2.0/src/agentprdiff/adapters/__init__.py +43 -0
- agentprdiff-0.2.0/src/agentprdiff/adapters/anthropic.py +191 -0
- agentprdiff-0.2.0/src/agentprdiff/adapters/openai.py +343 -0
- agentprdiff-0.2.0/src/agentprdiff/adapters/pricing.py +136 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/loader.py +29 -4
- agentprdiff-0.1.0/CHANGELOG.md +0 -37
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/LICENSE +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/examples/quickstart/README.md +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/cli.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/core.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/differ.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/graders/__init__.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/graders/deterministic.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/graders/semantic.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/reporters.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/runner.py +0 -0
- {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/store.py +0 -0
|
@@ -26,7 +26,7 @@ env/
|
|
|
26
26
|
.vscode/
|
|
27
27
|
*.swp
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
# Users SHOULD commit .
|
|
31
|
-
.
|
|
32
|
-
.
|
|
29
|
+
# agentprdiff runtime
|
|
30
|
+
# Users SHOULD commit .agentprdiff/baselines; they should NOT commit run artifacts.
|
|
31
|
+
.agentprdiff/runs/
|
|
32
|
+
.agentprdiff/cache/
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `agentprdiff` are documented in this file. Originally
|
|
4
|
+
prototyped under the name `tracediff`; renamed before first public release.
|
|
5
|
+
|
|
6
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
|
+
|
|
9
|
+
## [0.2.0] — 2026-04-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **SDK adapters** for the two dominant agent toolchains, eliminating the need
|
|
14
|
+
for manual `Trace` instrumentation:
|
|
15
|
+
- `agentprdiff.adapters.openai.instrument_client` — context manager that
|
|
16
|
+
monkey-patches `client.chat.completions.create` for the duration of one
|
|
17
|
+
agent call. Records each invocation as an `LLMCall` (provider, model,
|
|
18
|
+
input messages, output text, tool calls, tokens, cost, latency) and
|
|
19
|
+
restores the original on exit. Works with **OpenAI, Groq, Gemini's
|
|
20
|
+
OpenAI-compatible endpoint, OpenRouter, Ollama, vLLM, Together,
|
|
21
|
+
Fireworks, DeepInfra**, and any other SDK that follows the OpenAI client
|
|
22
|
+
shape.
|
|
23
|
+
- `agentprdiff.adapters.anthropic.instrument_client` — equivalent for the
|
|
24
|
+
Anthropic Messages API (`client.messages.create`). Handles the
|
|
25
|
+
content-block response shape (text + `tool_use` blocks) and the
|
|
26
|
+
Messages-API token field names.
|
|
27
|
+
- `instrument_tools(tool_map, trace)` — wraps a dict of callables so each
|
|
28
|
+
invocation records a `ToolCall` with name, arguments, result, latency,
|
|
29
|
+
and any raised exception. Shared between both adapters.
|
|
30
|
+
- `agentprdiff.adapters.pricing` — curated model→price table for cost
|
|
31
|
+
estimation, with `register_prices()` and per-call `prices=` overrides.
|
|
32
|
+
Unknown models record `cost_usd=0.0` and emit a single `RuntimeWarning`
|
|
33
|
+
per process so missing pricing is loud rather than silent.
|
|
34
|
+
- Documentation: `docs/adapters.md` (full reference) and
|
|
35
|
+
`docs/adapters-vercel.md` (manual integration recipe for the Vercel AI
|
|
36
|
+
SDK, which is JS-only and lives in a future companion package).
|
|
37
|
+
- `AGENTS.md` at the repo root — an instruction set written for AI
|
|
38
|
+
coding agents (Claude Code, Cursor, Aider, etc.) that have been asked
|
|
39
|
+
to add `agentprdiff` to a codebase. Covers codebase discovery,
|
|
40
|
+
contract identification, wrap-the-agent recipes (OpenAI / Anthropic /
|
|
41
|
+
custom), stub patterns, suite scaffolding, baseline recording, CI
|
|
42
|
+
wiring, common pitfalls, and a validation checklist. Optimized for
|
|
43
|
+
AI-agent-driven adoption with copy-paste templates.
|
|
44
|
+
- `docs/ai-driven-adoption.md` — human-facing companion to AGENTS.md.
|
|
45
|
+
Three prompt templates (minimum viable / recommended / contract-driven)
|
|
46
|
+
for adopters using Claude Code / Cursor / Aider, plus a sample
|
|
47
|
+
first-session transcript and tips for working with the AI agent
|
|
48
|
+
through the adoption flow.
|
|
49
|
+
- `docs/suite-layout.md` — canonical reference for the suite directory
|
|
50
|
+
structure. Lists each file (`suites/<project>.py`, `_eval_agent.py`,
|
|
51
|
+
`_stubs.py`, baselines, CI workflow, etc.), classifies them as
|
|
52
|
+
mandatory / recommended / optional, and specifies what each must
|
|
53
|
+
and must not contain. Cross-referenced from AGENTS.md and the
|
|
54
|
+
validation checklist.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- The suite loader now inserts the current working directory onto
|
|
59
|
+
`sys.path` in addition to the suite file's parent directory. Adopters
|
|
60
|
+
who run `agentprdiff record suites/foo.py` from their project root no
|
|
61
|
+
longer have to manually patch `sys.path` to import their own modules
|
|
62
|
+
(e.g. `from agent.agent import ...`, `from config import ...`).
|
|
63
|
+
Both insertions are reverted after the suite loads, so no path leakage
|
|
64
|
+
between runs.
|
|
65
|
+
|
|
66
|
+
### Notes
|
|
67
|
+
|
|
68
|
+
- The base `pip install agentprdiff` does **not** require the `openai` or
|
|
69
|
+
`anthropic` packages. The adapters operate on a client object's shape,
|
|
70
|
+
not on imported SDK modules — so installing only the SDKs you actually
|
|
71
|
+
use keeps the dependency footprint small. Optional extras are still
|
|
72
|
+
declared (`agentprdiff[openai]`, `agentprdiff[anthropic]`) for adopters
|
|
73
|
+
who prefer to pin the SDK version alongside agentprdiff itself.
|
|
74
|
+
|
|
75
|
+
## [0.1.0] — 2026-04-22
|
|
76
|
+
|
|
77
|
+
Initial public release.
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
|
|
81
|
+
- Core `Suite` / `Case` / `Trace` model for defining agent regression tests.
|
|
82
|
+
- Deterministic graders: `contains`, `contains_any`, `regex_match`, `tool_called`,
|
|
83
|
+
`tool_sequence`, `output_length_lt`, `latency_lt_ms`, `cost_lt_usd`,
|
|
84
|
+
`no_tool_called`.
|
|
85
|
+
- Semantic grader (`semantic`) with a pluggable `judge` callable and built-in
|
|
86
|
+
fake judge for CI environments without API keys.
|
|
87
|
+
- Baseline store (JSON files under `.agentprdiff/baselines/`) designed to be
|
|
88
|
+
committed to version control.
|
|
89
|
+
- Trace diff engine producing a structured `TraceDelta` (assertion pass/fail
|
|
90
|
+
changes, cost delta, latency delta, tool-call sequence changes, output
|
|
91
|
+
change).
|
|
92
|
+
- CLI: `agentprdiff init`, `agentprdiff record`, `agentprdiff check`, `agentprdiff diff`.
|
|
93
|
+
- Rich-formatted terminal reporter and machine-readable JSON reporter for CI.
|
|
94
|
+
- Quickstart example with a mock agent that runs without any API keys.
|
|
95
|
+
- Pytest test suite covering graders, runner, differ, store, and CLI smoke.
|
|
96
|
+
- GitHub Actions CI workflow.
|
|
97
|
+
|
|
98
|
+
### Known limitations
|
|
99
|
+
|
|
100
|
+
- Only a manual instrumentation API for provider SDKs is shipped in 0.1.0.
|
|
101
|
+
Drop-in wrappers for OpenAI / Anthropic / Vercel AI SDK are planned for 0.2.
|
|
102
|
+
- The semantic grader's built-in judge supports OpenAI and Anthropic via user-
|
|
103
|
+
supplied API keys; hosted judge endpoints are not yet offered.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentprdiff
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Guard your LLM agents in CI. Snapshot tests that catch behavioral regressions when models, prompts, or vendors change.
|
|
5
5
|
Project-URL: Homepage, https://github.com/vnageshwaran-de/agentprdiff
|
|
6
6
|
Project-URL: Documentation, https://github.com/vnageshwaran-de/agentprdiff#readme
|
|
@@ -50,6 +50,8 @@ pip install agentprdiff
|
|
|
50
50
|
[](https://pypi.org/project/agentprdiff/)
|
|
51
51
|
[](./LICENSE)
|
|
52
52
|
|
|
53
|
+
> **Adopting with an AI coding agent?** Point Claude Code, Cursor, Aider, or any agentic IDE at [`AGENTS.md`](./AGENTS.md) — a step-by-step adoption playbook the agent reads directly. Humans driving the adoption: see [`docs/ai-driven-adoption.md`](./docs/ai-driven-adoption.md) for copy-paste prompt templates. The canonical file layout — what's mandatory, what's recommended, what's optional — is at [`docs/suite-layout.md`](./docs/suite-layout.md).
|
|
54
|
+
|
|
53
55
|
## Why
|
|
54
56
|
|
|
55
57
|
Unit tests assume determinism. Agents aren't deterministic, but they do have *behaviors you rely on* — a specific tool gets called, a refund amount is quoted, a latency budget is respected, a safety guardrail fires. When a model or prompt changes, those behaviors drift. Today most teams find out in production.
|
|
@@ -100,6 +102,7 @@ That's the whole product. Four CLI commands. One Python file. Zero framework loc
|
|
|
100
102
|
- **Diff engine** — per-case `TraceDelta` with assertion pass/fail changes, cost delta, latency delta, tool-sequence changes, and a unified output diff.
|
|
101
103
|
- **CI-ready CLI** — exit 1 on regression, `--json-out` for artifact archiving, Rich-formatted terminal output.
|
|
102
104
|
- **Zero SDK lock-in** — works with OpenAI, Anthropic, Gemini, Bedrock, LangChain, LangGraph, LlamaIndex, Vercel AI SDK, custom wrappers — if you can wrap your agent in a function, `agentprdiff` can test it.
|
|
105
|
+
- **One-line SDK adapters** — `with instrument_client(client) as trace:` automatically records every LLM and tool call when you're on the OpenAI Python SDK (or any OpenAI-compatible provider — Groq / Gemini / OpenRouter / Ollama / vLLM) or the Anthropic SDK. No manual `Trace` wiring required.
|
|
103
106
|
|
|
104
107
|
## How it compares
|
|
105
108
|
|
|
@@ -124,7 +127,34 @@ This is the same loop as Jest snapshot tests or VCR cassettes — applied to LLM
|
|
|
124
127
|
|
|
125
128
|
## Instrumenting your agent
|
|
126
129
|
|
|
127
|
-
|
|
130
|
+
You have two paths. Most agents need the first.
|
|
131
|
+
|
|
132
|
+
### Option A — SDK adapters (zero manual work)
|
|
133
|
+
|
|
134
|
+
If your agent uses the OpenAI Python SDK (or any OpenAI-compatible provider — Groq, Gemini, OpenRouter, Ollama, vLLM, Together, Fireworks, DeepInfra) or the Anthropic SDK, the SDK adapter captures every model and tool call automatically:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from openai import OpenAI
|
|
138
|
+
from agentprdiff.adapters.openai import instrument_client, instrument_tools
|
|
139
|
+
|
|
140
|
+
TOOL_MAP = {"lookup_order": lookup_order, "send_email": send_email}
|
|
141
|
+
|
|
142
|
+
def my_agent(query: str):
|
|
143
|
+
client = OpenAI()
|
|
144
|
+
with instrument_client(client) as trace:
|
|
145
|
+
tools = instrument_tools(TOOL_MAP, trace)
|
|
146
|
+
# ... your existing tool-calling loop, untouched ...
|
|
147
|
+
# the only swap: TOOL_MAP[fn](**args) → tools[fn](**args)
|
|
148
|
+
return final_text, trace
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The patch is scoped to the specific client instance and reversed when the `with` block exits — no global SDK state is touched. Anthropic adopters use `agentprdiff.adapters.anthropic` with the same shape.
|
|
152
|
+
|
|
153
|
+
See [`docs/adapters.md`](./docs/adapters.md) for the full reference, including pricing overrides, custom provider tags, and recipes for nested agents.
|
|
154
|
+
|
|
155
|
+
### Option B — Manual instrumentation
|
|
156
|
+
|
|
157
|
+
If you're not on either SDK, or you want full control, build the `Trace` yourself — `agentprdiff` doesn't require any monkey-patching:
|
|
128
158
|
|
|
129
159
|
```python
|
|
130
160
|
from agentprdiff import Trace, LLMCall, ToolCall
|
|
@@ -13,6 +13,8 @@ pip install agentprdiff
|
|
|
13
13
|
[](https://pypi.org/project/agentprdiff/)
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
|
|
16
|
+
> **Adopting with an AI coding agent?** Point Claude Code, Cursor, Aider, or any agentic IDE at [`AGENTS.md`](./AGENTS.md) — a step-by-step adoption playbook the agent reads directly. Humans driving the adoption: see [`docs/ai-driven-adoption.md`](./docs/ai-driven-adoption.md) for copy-paste prompt templates. The canonical file layout — what's mandatory, what's recommended, what's optional — is at [`docs/suite-layout.md`](./docs/suite-layout.md).
|
|
17
|
+
|
|
16
18
|
## Why
|
|
17
19
|
|
|
18
20
|
Unit tests assume determinism. Agents aren't deterministic, but they do have *behaviors you rely on* — a specific tool gets called, a refund amount is quoted, a latency budget is respected, a safety guardrail fires. When a model or prompt changes, those behaviors drift. Today most teams find out in production.
|
|
@@ -63,6 +65,7 @@ That's the whole product. Four CLI commands. One Python file. Zero framework loc
|
|
|
63
65
|
- **Diff engine** — per-case `TraceDelta` with assertion pass/fail changes, cost delta, latency delta, tool-sequence changes, and a unified output diff.
|
|
64
66
|
- **CI-ready CLI** — exit 1 on regression, `--json-out` for artifact archiving, Rich-formatted terminal output.
|
|
65
67
|
- **Zero SDK lock-in** — works with OpenAI, Anthropic, Gemini, Bedrock, LangChain, LangGraph, LlamaIndex, Vercel AI SDK, custom wrappers — if you can wrap your agent in a function, `agentprdiff` can test it.
|
|
68
|
+
- **One-line SDK adapters** — `with instrument_client(client) as trace:` automatically records every LLM and tool call when you're on the OpenAI Python SDK (or any OpenAI-compatible provider — Groq / Gemini / OpenRouter / Ollama / vLLM) or the Anthropic SDK. No manual `Trace` wiring required.
|
|
66
69
|
|
|
67
70
|
## How it compares
|
|
68
71
|
|
|
@@ -87,7 +90,34 @@ This is the same loop as Jest snapshot tests or VCR cassettes — applied to LLM
|
|
|
87
90
|
|
|
88
91
|
## Instrumenting your agent
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
You have two paths. Most agents need the first.
|
|
94
|
+
|
|
95
|
+
### Option A — SDK adapters (zero manual work)
|
|
96
|
+
|
|
97
|
+
If your agent uses the OpenAI Python SDK (or any OpenAI-compatible provider — Groq, Gemini, OpenRouter, Ollama, vLLM, Together, Fireworks, DeepInfra) or the Anthropic SDK, the SDK adapter captures every model and tool call automatically:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from openai import OpenAI
|
|
101
|
+
from agentprdiff.adapters.openai import instrument_client, instrument_tools
|
|
102
|
+
|
|
103
|
+
TOOL_MAP = {"lookup_order": lookup_order, "send_email": send_email}
|
|
104
|
+
|
|
105
|
+
def my_agent(query: str):
|
|
106
|
+
client = OpenAI()
|
|
107
|
+
with instrument_client(client) as trace:
|
|
108
|
+
tools = instrument_tools(TOOL_MAP, trace)
|
|
109
|
+
# ... your existing tool-calling loop, untouched ...
|
|
110
|
+
# the only swap: TOOL_MAP[fn](**args) → tools[fn](**args)
|
|
111
|
+
return final_text, trace
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The patch is scoped to the specific client instance and reversed when the `with` block exits — no global SDK state is touched. Anthropic adopters use `agentprdiff.adapters.anthropic` with the same shape.
|
|
115
|
+
|
|
116
|
+
See [`docs/adapters.md`](./docs/adapters.md) for the full reference, including pricing overrides, custom provider tags, and recipes for nested agents.
|
|
117
|
+
|
|
118
|
+
### Option B — Manual instrumentation
|
|
119
|
+
|
|
120
|
+
If you're not on either SDK, or you want full control, build the `Trace` yourself — `agentprdiff` doesn't require any monkey-patching:
|
|
91
121
|
|
|
92
122
|
```python
|
|
93
123
|
from agentprdiff import Trace, LLMCall, ToolCall
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Regression tour
|
|
2
|
+
|
|
3
|
+
A complete walkthrough of every grader and every failure mode in `agentprdiff`. Runs without API keys (uses `fake_judge` for the semantic grader so no OpenAI/Anthropic key is required).
|
|
4
|
+
|
|
5
|
+
## What this exercises
|
|
6
|
+
|
|
7
|
+
- All 10 deterministic and semantic graders: `contains`, `contains_any`, `regex_match`, `tool_called`, `tool_sequence`, `no_tool_called`, `output_length_lt`, `latency_lt_ms`, `cost_lt_usd`, `semantic`.
|
|
8
|
+
- All 6 regression scenarios the differ can detect: output drift, extra tool, missing tool, tool reordering, latency regression, cost regression.
|
|
9
|
+
- The Rich terminal reporter and exit-code behavior used in CI.
|
|
10
|
+
|
|
11
|
+
## A note on invocation
|
|
12
|
+
|
|
13
|
+
The commands below use `agentprdiff` directly. If pip's user-script directory isn't on your PATH, substitute `python3 -m agentprdiff.cli` everywhere — both forms are equivalent.
|
|
14
|
+
|
|
15
|
+
## Setup (one-time)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cd examples/regression-tour
|
|
19
|
+
agentprdiff init # creates the .agentprdiff/ scaffolding
|
|
20
|
+
agentprdiff record suite.py # captures the baseline trace
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
You should see baselines written under `.agentprdiff/baselines/`. In a real project these get committed to git — that's the whole point of agentprdiff.
|
|
24
|
+
|
|
25
|
+
## Happy path
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
agentprdiff check suite.py
|
|
29
|
+
echo "exit: $?" # 0
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
All three cases pass with no diff against the recorded baseline.
|
|
33
|
+
|
|
34
|
+
## Regression scenarios
|
|
35
|
+
|
|
36
|
+
Each command below injects one specific regression by setting `MODE`. Every one should fail with a clear diff and a non-zero exit code.
|
|
37
|
+
|
|
38
|
+
### 1. Output text drifted
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
MODE=output_changed agentprdiff check suite.py
|
|
42
|
+
echo "exit: $?" # non-zero
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The agent's refund response changes from the baseline phrasing to "Refund initiated. Please allow 7–10 business days for processing." That trips:
|
|
46
|
+
|
|
47
|
+
- `contains("refund")` — still passes (the new text mentions refund)
|
|
48
|
+
- `contains_any(["business days", "card", "processed"])` — still passes
|
|
49
|
+
- `regex_match(r"\$\d+\.\d{2}")` — **fails** (no dollar amount in the new output)
|
|
50
|
+
- `output_length_lt(500)` — still passes
|
|
51
|
+
- `semantic(...)` — likely **fails** depending on judge backend
|
|
52
|
+
|
|
53
|
+
The terminal reporter prints a unified output diff so the reviewer can see exactly what changed.
|
|
54
|
+
|
|
55
|
+
### 2. Extra tool call
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
MODE=tool_added agentprdiff check suite.py
|
|
59
|
+
echo "exit: $?" # non-zero
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The agent calls `check_inventory` after `lookup_order`. That trips:
|
|
63
|
+
|
|
64
|
+
- `tool_sequence(["lookup_order"])` — **fails** (sequence is now `["lookup_order", "check_inventory"]`)
|
|
65
|
+
- `no_tool_called("check_inventory")` — **fails**
|
|
66
|
+
|
|
67
|
+
### 3. Missing tool call
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
MODE=tool_removed agentprdiff check suite.py
|
|
71
|
+
echo "exit: $?" # non-zero
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The agent never calls `lookup_order` and produces a fallback "trouble looking up your order" response. That trips:
|
|
75
|
+
|
|
76
|
+
- `tool_called("lookup_order")` — **fails**
|
|
77
|
+
- `tool_sequence(["lookup_order"])` — **fails**
|
|
78
|
+
- `contains("refund")` — **fails** (output text changed)
|
|
79
|
+
- `regex_match(r"\$\d+\.\d{2}")` — **fails**
|
|
80
|
+
|
|
81
|
+
### 4. Tool order swapped
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
MODE=tool_reordered agentprdiff check suite.py
|
|
85
|
+
echo "exit: $?" # non-zero
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The agent calls `check_inventory` *before* `lookup_order`. Same tools, wrong order:
|
|
89
|
+
|
|
90
|
+
- `tool_sequence(["lookup_order"])` — **fails** (sequence is `["check_inventory", "lookup_order"]`)
|
|
91
|
+
- `no_tool_called("check_inventory")` — **fails**
|
|
92
|
+
|
|
93
|
+
### 5. Latency regression
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
MODE=latency_regressed agentprdiff check suite.py
|
|
97
|
+
echo "exit: $?" # non-zero
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Planner LLM call jumps from 180 ms to 8 s, blowing past the 5 s cap:
|
|
101
|
+
|
|
102
|
+
- `latency_lt_ms(5_000)` — **fails**
|
|
103
|
+
|
|
104
|
+
The reporter shows the latency delta against baseline.
|
|
105
|
+
|
|
106
|
+
### 6. Cost regression
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
MODE=cost_regressed agentprdiff check suite.py
|
|
110
|
+
echo "exit: $?" # non-zero
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Responder cost jumps from $0.0008 to $0.10 per call:
|
|
114
|
+
|
|
115
|
+
- `cost_lt_usd(0.01)` — **fails**
|
|
116
|
+
|
|
117
|
+
The reporter shows the cost delta against baseline.
|
|
118
|
+
|
|
119
|
+
## Run them all back to back
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
./tour.sh
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Runs every scenario with banners, prints exit codes, and gives you a one-screen overview of what the tool detects.
|
|
126
|
+
|
|
127
|
+
## Resetting the baseline
|
|
128
|
+
|
|
129
|
+
If you want to make the new behavior the new baseline (e.g., you intentionally changed the agent), re-record:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
MODE=output_changed agentprdiff record suite.py # baseline now matches the changed output
|
|
133
|
+
agentprdiff check suite.py # passes against the new baseline
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This is the workflow you'll use in real projects when a model upgrade or prompt change is intentional.
|
|
137
|
+
|
|
138
|
+
## Why this matters
|
|
139
|
+
|
|
140
|
+
The point of `agentprdiff` is that every one of these scenarios should be caught **before** the change reaches production. Run `agentprdiff check` in CI on every PR and the merge is blocked when behavior changes — same as `pytest` blocks merges when tests break.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentprdiff"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Guard your LLM agents in CI. Snapshot tests that catch behavioral regressions when models, prompts, or vendors change."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -30,6 +30,22 @@ Run from the shell::
|
|
|
30
30
|
agentprdiff init
|
|
31
31
|
agentprdiff record path/to/my_suite.py # save baselines
|
|
32
32
|
agentprdiff check path/to/my_suite.py # diff against baselines; exit 1 on regression
|
|
33
|
+
|
|
34
|
+
If your agent already uses the OpenAI Python SDK (or any OpenAI-compatible
|
|
35
|
+
provider — Groq, Gemini, OpenRouter, Ollama, vLLM) or the Anthropic SDK, the
|
|
36
|
+
SDK adapters capture every model and tool call automatically, no manual Trace
|
|
37
|
+
wiring required::
|
|
38
|
+
|
|
39
|
+
from agentprdiff.adapters.openai import instrument_client, instrument_tools
|
|
40
|
+
|
|
41
|
+
def my_agent(query):
|
|
42
|
+
client = OpenAI(...)
|
|
43
|
+
with instrument_client(client) as trace:
|
|
44
|
+
tools = instrument_tools(TOOL_MAP, trace)
|
|
45
|
+
# ... your existing tool-calling loop, untouched ...
|
|
46
|
+
return final_text, trace
|
|
47
|
+
|
|
48
|
+
See ``docs/adapters.md`` for the full reference.
|
|
33
49
|
"""
|
|
34
50
|
|
|
35
51
|
from __future__ import annotations
|
|
@@ -51,7 +67,7 @@ from .differ import AssertionChange, TraceDelta, diff_traces
|
|
|
51
67
|
from .runner import CaseReport, Runner, RunReport
|
|
52
68
|
from .store import BaselineStore
|
|
53
69
|
|
|
54
|
-
__version__ = "0.
|
|
70
|
+
__version__ = "0.2.0"
|
|
55
71
|
|
|
56
72
|
__all__ = [
|
|
57
73
|
# core
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""SDK adapters for agentprdiff.
|
|
2
|
+
|
|
3
|
+
The adapters take an agent that uses a real LLM SDK (OpenAI, Anthropic, or any
|
|
4
|
+
OpenAI-compatible provider like Groq, Gemini's openai-compat endpoint,
|
|
5
|
+
OpenRouter, Ollama, or vLLM) and capture every model call as an `LLMCall` on a
|
|
6
|
+
`Trace` — without forcing the user to rewrite their agent loop.
|
|
7
|
+
|
|
8
|
+
The pattern is::
|
|
9
|
+
|
|
10
|
+
from agentprdiff.adapters.openai import instrument_client, instrument_tools
|
|
11
|
+
|
|
12
|
+
def my_agent(query: str):
|
|
13
|
+
client = OpenAI(...)
|
|
14
|
+
with instrument_client(client) as trace:
|
|
15
|
+
tools = instrument_tools(TOOL_MAP, trace)
|
|
16
|
+
# ... user's existing tool-calling loop, untouched ...
|
|
17
|
+
return final_text, trace
|
|
18
|
+
|
|
19
|
+
Submodules are imported lazily so the base `agentprdiff` install doesn't pull
|
|
20
|
+
in `openai` / `anthropic` unless the user opts in via the extras::
|
|
21
|
+
|
|
22
|
+
pip install "agentprdiff[openai]"
|
|
23
|
+
pip install "agentprdiff[anthropic]"
|
|
24
|
+
|
|
25
|
+
See `docs/adapters.md` for the full reference.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
# Re-export pricing helpers — these are pure-Python and have no SDK dependency.
|
|
31
|
+
from .pricing import (
|
|
32
|
+
DEFAULT_PRICES,
|
|
33
|
+
PriceTable,
|
|
34
|
+
estimate_cost_usd,
|
|
35
|
+
register_prices,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DEFAULT_PRICES",
|
|
40
|
+
"PriceTable",
|
|
41
|
+
"estimate_cost_usd",
|
|
42
|
+
"register_prices",
|
|
43
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter.
|
|
2
|
+
|
|
3
|
+
The Messages API has a different shape from OpenAI's Chat Completions:
|
|
4
|
+
|
|
5
|
+
* Output is a list of content blocks (``text``, ``tool_use``, ``thinking``,
|
|
6
|
+
...) on ``response.content``, not ``response.choices[0].message.content``.
|
|
7
|
+
* Token usage is ``response.usage.input_tokens`` / ``output_tokens``.
|
|
8
|
+
* Tool calls live as ``tool_use`` blocks inside ``response.content``, each with
|
|
9
|
+
an ``id``, ``name``, and ``input`` dict.
|
|
10
|
+
|
|
11
|
+
The user's loop pattern is also different — they iterate the content blocks,
|
|
12
|
+
execute matching tools, and feed back ``tool_result`` blocks. We don't try to
|
|
13
|
+
hide that; we just record whatever Anthropic returns, on the same ``Trace``
|
|
14
|
+
data model the rest of agentprdiff uses.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
from anthropic import Anthropic
|
|
19
|
+
from agentprdiff.adapters.anthropic import instrument_client, instrument_tools
|
|
20
|
+
|
|
21
|
+
def my_agent(query: str):
|
|
22
|
+
client = Anthropic()
|
|
23
|
+
with instrument_client(client) as trace:
|
|
24
|
+
tools = instrument_tools(TOOL_MAP, trace)
|
|
25
|
+
# ...standard Anthropic Messages tool-use loop...
|
|
26
|
+
return final_text, trace
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import time
|
|
32
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
33
|
+
from contextlib import contextmanager, suppress
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from ..core import LLMCall, Trace
|
|
37
|
+
from .openai import _jsonable, _make_tool_wrapper, _serialize_messages
|
|
38
|
+
from .pricing import PriceTable, estimate_cost_usd
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_anthropic_blocks(content: Any) -> tuple[str, list[dict[str, Any]]]:
|
|
42
|
+
"""Walk Anthropic content blocks and extract output text + tool_use calls.
|
|
43
|
+
|
|
44
|
+
Returns ``(output_text, tool_calls_summary)``. Any thinking / redacted /
|
|
45
|
+
unknown block types are quietly ignored — they're not asserted against by
|
|
46
|
+
any current grader.
|
|
47
|
+
"""
|
|
48
|
+
output_parts: list[str] = []
|
|
49
|
+
tool_calls: list[dict[str, Any]] = []
|
|
50
|
+
if not content:
|
|
51
|
+
return "", []
|
|
52
|
+
for block in content:
|
|
53
|
+
btype = getattr(block, "type", None)
|
|
54
|
+
if btype is None and isinstance(block, dict):
|
|
55
|
+
btype = block.get("type")
|
|
56
|
+
if btype == "text":
|
|
57
|
+
text = getattr(block, "text", None)
|
|
58
|
+
if text is None and isinstance(block, dict):
|
|
59
|
+
text = block.get("text", "")
|
|
60
|
+
output_parts.append(text or "")
|
|
61
|
+
elif btype == "tool_use":
|
|
62
|
+
name = getattr(block, "name", None)
|
|
63
|
+
inputs = getattr(block, "input", None)
|
|
64
|
+
tu_id = getattr(block, "id", None)
|
|
65
|
+
if name is None and isinstance(block, dict):
|
|
66
|
+
name = block.get("name")
|
|
67
|
+
inputs = block.get("input")
|
|
68
|
+
tu_id = block.get("id")
|
|
69
|
+
if name:
|
|
70
|
+
tool_calls.append(
|
|
71
|
+
{
|
|
72
|
+
"id": tu_id,
|
|
73
|
+
"name": name,
|
|
74
|
+
"arguments": inputs or {},
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
return "".join(output_parts), tool_calls
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextmanager
|
|
81
|
+
def instrument_client(
|
|
82
|
+
client: Any,
|
|
83
|
+
*,
|
|
84
|
+
trace: Trace | None = None,
|
|
85
|
+
prices: PriceTable | None = None,
|
|
86
|
+
provider: str | None = None,
|
|
87
|
+
) -> Iterator[Trace]:
|
|
88
|
+
"""Patch ``client.messages.create`` to record onto a Trace.
|
|
89
|
+
|
|
90
|
+
See the OpenAI adapter docstring for parameter semantics — they match.
|
|
91
|
+
"""
|
|
92
|
+
if trace is None:
|
|
93
|
+
trace = Trace(suite_name="", case_name="", input=None)
|
|
94
|
+
provider_str = provider or "anthropic"
|
|
95
|
+
|
|
96
|
+
messages_attr = getattr(client, "messages", None)
|
|
97
|
+
if messages_attr is None or not hasattr(messages_attr, "create"):
|
|
98
|
+
raise TypeError(
|
|
99
|
+
"instrument_client expected an Anthropic client with "
|
|
100
|
+
"client.messages.create; got "
|
|
101
|
+
f"{type(client).__name__}."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
original_create: Callable[..., Any] = messages_attr.create
|
|
105
|
+
had_instance_attr = "create" in vars(messages_attr)
|
|
106
|
+
instance_attr_value = vars(messages_attr).get("create")
|
|
107
|
+
|
|
108
|
+
def patched_create(*args: Any, **kwargs: Any) -> Any:
|
|
109
|
+
start = time.perf_counter()
|
|
110
|
+
try:
|
|
111
|
+
response = original_create(*args, **kwargs)
|
|
112
|
+
except Exception as exc: # noqa: BLE001
|
|
113
|
+
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
114
|
+
trace.record_llm_call(
|
|
115
|
+
LLMCall(
|
|
116
|
+
provider=provider_str,
|
|
117
|
+
model=str(kwargs.get("model", "")),
|
|
118
|
+
input_messages=_serialize_messages(kwargs.get("messages")),
|
|
119
|
+
output_text=f"<exception: {type(exc).__name__}: {exc}>",
|
|
120
|
+
latency_ms=elapsed_ms,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
126
|
+
|
|
127
|
+
usage = getattr(response, "usage", None)
|
|
128
|
+
prompt_tokens = int(getattr(usage, "input_tokens", 0) or 0)
|
|
129
|
+
completion_tokens = int(getattr(usage, "output_tokens", 0) or 0)
|
|
130
|
+
model_id = str(getattr(response, "model", "") or kwargs.get("model", "") or "")
|
|
131
|
+
|
|
132
|
+
content = getattr(response, "content", None)
|
|
133
|
+
output_text, tool_calls_summary = _extract_anthropic_blocks(content)
|
|
134
|
+
|
|
135
|
+
cost = estimate_cost_usd(
|
|
136
|
+
model_id,
|
|
137
|
+
prompt_tokens=prompt_tokens,
|
|
138
|
+
completion_tokens=completion_tokens,
|
|
139
|
+
prices=prices,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
trace.record_llm_call(
|
|
143
|
+
LLMCall(
|
|
144
|
+
provider=provider_str,
|
|
145
|
+
model=model_id,
|
|
146
|
+
input_messages=_serialize_messages(kwargs.get("messages")),
|
|
147
|
+
output_text=output_text,
|
|
148
|
+
tool_calls=tool_calls_summary,
|
|
149
|
+
prompt_tokens=prompt_tokens,
|
|
150
|
+
completion_tokens=completion_tokens,
|
|
151
|
+
cost_usd=cost,
|
|
152
|
+
latency_ms=elapsed_ms,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
messages_attr.create = patched_create # type: ignore[method-assign]
|
|
158
|
+
try:
|
|
159
|
+
yield trace
|
|
160
|
+
finally:
|
|
161
|
+
if had_instance_attr:
|
|
162
|
+
messages_attr.create = instance_attr_value # type: ignore[method-assign]
|
|
163
|
+
else:
|
|
164
|
+
with suppress(AttributeError):
|
|
165
|
+
del messages_attr.create # type: ignore[attr-defined]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def instrument_tools(
|
|
169
|
+
tool_map: Mapping[str, Callable[..., Any]],
|
|
170
|
+
trace: Trace,
|
|
171
|
+
) -> dict[str, Callable[..., Any]]:
|
|
172
|
+
"""Wrap each tool callable to record a ``ToolCall`` per invocation.
|
|
173
|
+
|
|
174
|
+
Identical semantics to the OpenAI adapter version — the data model is
|
|
175
|
+
SDK-agnostic. We re-export here so adopters can do the natural::
|
|
176
|
+
|
|
177
|
+
from agentprdiff.adapters.anthropic import instrument_client, instrument_tools
|
|
178
|
+
"""
|
|
179
|
+
wrapped: dict[str, Callable[..., Any]] = {}
|
|
180
|
+
for name, fn in tool_map.items():
|
|
181
|
+
wrapped[name] = _make_tool_wrapper(name, fn, trace)
|
|
182
|
+
return wrapped
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Re-export the helpers so tests / advanced users don't have to import from
|
|
186
|
+
# the OpenAI module explicitly when they're already in the Anthropic adapter.
|
|
187
|
+
__all__ = [
|
|
188
|
+
"instrument_client",
|
|
189
|
+
"instrument_tools",
|
|
190
|
+
"_jsonable",
|
|
191
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""OpenAI / OpenAI-compatible adapter.
|
|
2
|
+
|
|
3
|
+
The adapter monkey-patches ``client.chat.completions.create`` for the duration
|
|
4
|
+
of a ``with`` block, recording one ``LLMCall`` per invocation onto a ``Trace``.
|
|
5
|
+
The patch is reversed on exit, so the user's client object behaves identically
|
|
6
|
+
outside the ``with`` block.
|
|
7
|
+
|
|
8
|
+
This adapter works with any SDK that uses the OpenAI Python client shape:
|
|
9
|
+
|
|
10
|
+
* OpenAI itself
|
|
11
|
+
* Groq (``base_url="https://api.groq.com/openai/v1"``)
|
|
12
|
+
* Google Gemini's OpenAI-compatible endpoint
|
|
13
|
+
* OpenRouter
|
|
14
|
+
* Ollama (``base_url="http://localhost:11434/v1"``)
|
|
15
|
+
* vLLM, Together, Fireworks, DeepInfra, Anyscale, etc.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
from agentprdiff.adapters.openai import instrument_client, instrument_tools
|
|
20
|
+
|
|
21
|
+
def my_agent(query: str):
|
|
22
|
+
client = OpenAI(api_key=...)
|
|
23
|
+
with instrument_client(client) as trace:
|
|
24
|
+
tools = instrument_tools(TOOL_MAP, trace)
|
|
25
|
+
# ...standard OpenAI tool-calling loop, unchanged...
|
|
26
|
+
return final_text, trace
|
|
27
|
+
|
|
28
|
+
The Trace's ``suite_name`` / ``case_name`` / ``input`` are filled in by
|
|
29
|
+
``run_agent`` after the agent returns; you can leave them blank inside the
|
|
30
|
+
adapter.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import time
|
|
36
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
37
|
+
from contextlib import contextmanager, suppress
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from ..core import LLMCall, ToolCall, Trace
|
|
41
|
+
from .pricing import PriceTable, estimate_cost_usd
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _infer_provider_from_client(client: Any) -> str:
|
|
45
|
+
"""Best-effort guess at the underlying provider from the client's base_url.
|
|
46
|
+
|
|
47
|
+
Falls back to ``"openai-compatible"`` if we can't tell. The provider string
|
|
48
|
+
only affects the recorded `LLMCall.provider` field — it doesn't change
|
|
49
|
+
behavior — so a fuzzy match is fine.
|
|
50
|
+
"""
|
|
51
|
+
base_url = ""
|
|
52
|
+
try:
|
|
53
|
+
base_url = str(getattr(client, "base_url", "") or "")
|
|
54
|
+
except Exception: # noqa: BLE001
|
|
55
|
+
return "openai-compatible"
|
|
56
|
+
|
|
57
|
+
url = base_url.lower()
|
|
58
|
+
if "groq" in url:
|
|
59
|
+
return "groq"
|
|
60
|
+
if "openrouter" in url:
|
|
61
|
+
return "openrouter"
|
|
62
|
+
if "googleapis" in url or "generativelanguage" in url:
|
|
63
|
+
return "gemini"
|
|
64
|
+
if "ollama" in url or "11434" in url:
|
|
65
|
+
return "ollama"
|
|
66
|
+
if "together" in url:
|
|
67
|
+
return "together"
|
|
68
|
+
if "fireworks" in url:
|
|
69
|
+
return "fireworks"
|
|
70
|
+
if "deepinfra" in url:
|
|
71
|
+
return "deepinfra"
|
|
72
|
+
if "anthropic" in url:
|
|
73
|
+
# Anthropic's OpenAI-compat shim. Use the native adapter instead for
|
|
74
|
+
# full fidelity, but still flag it.
|
|
75
|
+
return "anthropic-openai-compat"
|
|
76
|
+
if "openai" in url or url == "":
|
|
77
|
+
return "openai"
|
|
78
|
+
return "openai-compatible"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_tool_calls(message: Any) -> list[dict[str, Any]]:
|
|
82
|
+
"""Pull tool_calls off a ChatCompletionMessage in a defensive way."""
|
|
83
|
+
raw = getattr(message, "tool_calls", None) or []
|
|
84
|
+
out: list[dict[str, Any]] = []
|
|
85
|
+
for tc in raw:
|
|
86
|
+
# OpenAI SDK objects are pydantic-like; fall through to dict access too.
|
|
87
|
+
try:
|
|
88
|
+
fn = tc.function
|
|
89
|
+
name = getattr(fn, "name", None)
|
|
90
|
+
arguments = getattr(fn, "arguments", None)
|
|
91
|
+
tc_id = getattr(tc, "id", None)
|
|
92
|
+
except AttributeError:
|
|
93
|
+
try:
|
|
94
|
+
fn = tc.get("function", {})
|
|
95
|
+
name = fn.get("name")
|
|
96
|
+
arguments = fn.get("arguments")
|
|
97
|
+
tc_id = tc.get("id")
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
continue
|
|
100
|
+
if name is None:
|
|
101
|
+
continue
|
|
102
|
+
out.append({"id": tc_id, "name": name, "arguments": arguments})
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _serialize_messages(messages: Any) -> list[dict[str, Any]]:
|
|
107
|
+
"""Best-effort JSON-friendly copy of the request messages.
|
|
108
|
+
|
|
109
|
+
Trace baselines must be JSON-serializable, and `messages` is sometimes a
|
|
110
|
+
list of pydantic objects, sometimes a list of plain dicts. Prefer dicts;
|
|
111
|
+
skip anything we can't represent cleanly.
|
|
112
|
+
"""
|
|
113
|
+
if not messages:
|
|
114
|
+
return []
|
|
115
|
+
out: list[dict[str, Any]] = []
|
|
116
|
+
for m in messages:
|
|
117
|
+
if isinstance(m, dict):
|
|
118
|
+
out.append(m)
|
|
119
|
+
continue
|
|
120
|
+
# pydantic-ish?
|
|
121
|
+
for attr in ("model_dump", "dict"):
|
|
122
|
+
dump = getattr(m, attr, None)
|
|
123
|
+
if callable(dump):
|
|
124
|
+
try:
|
|
125
|
+
out.append(dump())
|
|
126
|
+
break
|
|
127
|
+
except Exception: # noqa: BLE001
|
|
128
|
+
pass
|
|
129
|
+
else:
|
|
130
|
+
# Last resort — string repr so the trace round-trips.
|
|
131
|
+
out.append({"_repr": repr(m)})
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@contextmanager
|
|
136
|
+
def instrument_client(
|
|
137
|
+
client: Any,
|
|
138
|
+
*,
|
|
139
|
+
trace: Trace | None = None,
|
|
140
|
+
prices: PriceTable | None = None,
|
|
141
|
+
provider: str | None = None,
|
|
142
|
+
) -> Iterator[Trace]:
|
|
143
|
+
"""Patch ``client.chat.completions.create`` to record onto a Trace.
|
|
144
|
+
|
|
145
|
+
Yields the ``Trace`` so the caller can return ``(output, trace)`` from
|
|
146
|
+
their agent function. The patch is restored on exit even if the agent
|
|
147
|
+
raises.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
client:
|
|
152
|
+
An ``openai.OpenAI`` (or compatible) client instance. We patch a bound
|
|
153
|
+
attribute on this specific instance — global SDK state is untouched.
|
|
154
|
+
trace:
|
|
155
|
+
Optional pre-existing ``Trace`` to record into. Useful when nesting
|
|
156
|
+
adapters or stitching together a multi-stage agent. If omitted, we
|
|
157
|
+
create a fresh one with empty suite/case/input fields (the runner
|
|
158
|
+
fills them in after the agent returns).
|
|
159
|
+
prices:
|
|
160
|
+
Optional override for the model→price table. See
|
|
161
|
+
:mod:`agentprdiff.adapters.pricing`.
|
|
162
|
+
provider:
|
|
163
|
+
Optional explicit provider tag; otherwise we infer from
|
|
164
|
+
``client.base_url``.
|
|
165
|
+
"""
|
|
166
|
+
if trace is None:
|
|
167
|
+
trace = Trace(suite_name="", case_name="", input=None)
|
|
168
|
+
provider_str = provider or _infer_provider_from_client(client)
|
|
169
|
+
|
|
170
|
+
# Locate the create function we need to patch. Newer SDKs:
|
|
171
|
+
# client.chat.completions.create
|
|
172
|
+
chat = getattr(client, "chat", None)
|
|
173
|
+
completions = getattr(chat, "completions", None) if chat is not None else None
|
|
174
|
+
if completions is None or not hasattr(completions, "create"):
|
|
175
|
+
raise TypeError(
|
|
176
|
+
"instrument_client expected an OpenAI-style client with "
|
|
177
|
+
"client.chat.completions.create; got "
|
|
178
|
+
f"{type(client).__name__}. If you're using a non-OpenAI SDK, see "
|
|
179
|
+
"agentprdiff/adapters/anthropic.py or open an issue."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Stash the original so we can call through, plus remember whether the
|
|
183
|
+
# attribute was carried as an instance attr or was descriptor-resolved
|
|
184
|
+
# from the class. We need that to cleanly restore on exit (del-on-exit if
|
|
185
|
+
# it wasn't originally an instance attr; assign-on-exit if it was).
|
|
186
|
+
original_create: Callable[..., Any] = completions.create
|
|
187
|
+
had_instance_attr = "create" in vars(completions)
|
|
188
|
+
instance_attr_value = vars(completions).get("create")
|
|
189
|
+
|
|
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,
|
|
227
|
+
)
|
|
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
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return response
|
|
243
|
+
|
|
244
|
+
# Apply the patch on this specific instance only.
|
|
245
|
+
completions.create = patched_create # type: ignore[method-assign]
|
|
246
|
+
try:
|
|
247
|
+
yield trace
|
|
248
|
+
finally:
|
|
249
|
+
if had_instance_attr:
|
|
250
|
+
completions.create = instance_attr_value # type: ignore[method-assign]
|
|
251
|
+
else:
|
|
252
|
+
# Drop the instance attribute so the original class-level
|
|
253
|
+
# descriptor (the bound method) shines through again.
|
|
254
|
+
# Defensive: someone else already cleaned up → no-op.
|
|
255
|
+
with suppress(AttributeError):
|
|
256
|
+
del completions.create # type: ignore[attr-defined]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def instrument_tools(
|
|
260
|
+
tool_map: Mapping[str, Callable[..., Any]],
|
|
261
|
+
trace: Trace,
|
|
262
|
+
) -> dict[str, Callable[..., Any]]:
|
|
263
|
+
"""Wrap each callable in ``tool_map`` so invocations record ``ToolCall``s.
|
|
264
|
+
|
|
265
|
+
Returns a new dict — the original is untouched. Use it the same way you'd
|
|
266
|
+
use the original::
|
|
267
|
+
|
|
268
|
+
tools = instrument_tools(TOOL_MAP, trace)
|
|
269
|
+
result = tools[fn_name](**fn_args)
|
|
270
|
+
|
|
271
|
+
Each call records:
|
|
272
|
+
|
|
273
|
+
* ``name`` — the dict key
|
|
274
|
+
* ``arguments`` — the kwargs (and positional args under ``"_args"`` if any)
|
|
275
|
+
* ``result`` — the return value (or None if the call raised)
|
|
276
|
+
* ``latency_ms`` — wall-clock latency
|
|
277
|
+
* ``error`` — exception text on failure
|
|
278
|
+
"""
|
|
279
|
+
wrapped: dict[str, Callable[..., Any]] = {}
|
|
280
|
+
for name, fn in tool_map.items():
|
|
281
|
+
wrapped[name] = _make_tool_wrapper(name, fn, trace)
|
|
282
|
+
return wrapped
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _make_tool_wrapper(
|
|
286
|
+
name: str,
|
|
287
|
+
fn: Callable[..., Any],
|
|
288
|
+
trace: Trace,
|
|
289
|
+
) -> Callable[..., Any]:
|
|
290
|
+
def _wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
291
|
+
start = time.perf_counter()
|
|
292
|
+
arguments: dict[str, Any] = dict(kwargs)
|
|
293
|
+
if args:
|
|
294
|
+
arguments["_args"] = list(args)
|
|
295
|
+
try:
|
|
296
|
+
result = fn(*args, **kwargs)
|
|
297
|
+
except Exception as exc: # noqa: BLE001
|
|
298
|
+
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
|
+
)
|
|
307
|
+
)
|
|
308
|
+
raise
|
|
309
|
+
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
310
|
+
trace.record_tool_call(
|
|
311
|
+
ToolCall(
|
|
312
|
+
name=name,
|
|
313
|
+
arguments=arguments,
|
|
314
|
+
result=_jsonable(result),
|
|
315
|
+
latency_ms=elapsed_ms,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
_wrapped.__name__ = f"instrumented_{name}"
|
|
321
|
+
_wrapped.__doc__ = getattr(fn, "__doc__", None)
|
|
322
|
+
return _wrapped
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _jsonable(value: Any) -> Any:
|
|
326
|
+
"""Best-effort coerce a tool's return value to something JSON-serializable.
|
|
327
|
+
|
|
328
|
+
Pydantic models get model_dump'd; primitive types, lists, and dicts pass
|
|
329
|
+
through; anything else falls back to repr.
|
|
330
|
+
"""
|
|
331
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
332
|
+
return value
|
|
333
|
+
if isinstance(value, (list, tuple)):
|
|
334
|
+
return [_jsonable(v) for v in value]
|
|
335
|
+
if isinstance(value, dict):
|
|
336
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
337
|
+
dump = getattr(value, "model_dump", None)
|
|
338
|
+
if callable(dump):
|
|
339
|
+
try:
|
|
340
|
+
return dump(mode="json")
|
|
341
|
+
except Exception: # noqa: BLE001
|
|
342
|
+
pass
|
|
343
|
+
return repr(value)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Per-model price table used by SDK adapters to fill in `LLMCall.cost_usd`.
|
|
2
|
+
|
|
3
|
+
The shape of an entry is::
|
|
4
|
+
|
|
5
|
+
"model-id": (input_usd_per_1k_tokens, output_usd_per_1k_tokens)
|
|
6
|
+
|
|
7
|
+
Prices change. We ship a curated default table that's accurate at release time
|
|
8
|
+
and intentionally easy to override:
|
|
9
|
+
|
|
10
|
+
* Per-call: pass `prices=` to `instrument_client(...)`.
|
|
11
|
+
* Per-model: call `register_prices({"my-model": (0.001, 0.002)})` once at
|
|
12
|
+
import time.
|
|
13
|
+
* Globally: replace `DEFAULT_PRICES` with your own dict.
|
|
14
|
+
|
|
15
|
+
If a model is not in the table, the adapter records `cost_usd=0.0` and emits
|
|
16
|
+
exactly one `RuntimeWarning` per process per model name, so cost regressions
|
|
17
|
+
based on missing pricing are loud rather than silent.
|
|
18
|
+
|
|
19
|
+
Sources for the bundled defaults: each provider's published pricing page as of
|
|
20
|
+
2026-04. Submit a PR if you spot drift.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import warnings
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
|
|
28
|
+
# (input_$/1k, output_$/1k)
|
|
29
|
+
PriceTable = Mapping[str, tuple[float, float]]
|
|
30
|
+
|
|
31
|
+
# Curated defaults. Conservative — if pricing tiers exist (e.g. cached input vs
|
|
32
|
+
# fresh input on Anthropic), we use the headline number. Override per-call for
|
|
33
|
+
# precision.
|
|
34
|
+
DEFAULT_PRICES: dict[str, tuple[float, float]] = {
|
|
35
|
+
# ── OpenAI ─────────────────────────────────────────────────────────────
|
|
36
|
+
"gpt-4o": (0.0025, 0.0100),
|
|
37
|
+
"gpt-4o-2024-08-06": (0.0025, 0.0100),
|
|
38
|
+
"gpt-4o-mini": (0.00015, 0.00060),
|
|
39
|
+
"gpt-4-turbo": (0.0100, 0.0300),
|
|
40
|
+
"gpt-4": (0.0300, 0.0600),
|
|
41
|
+
"gpt-3.5-turbo": (0.0005, 0.0015),
|
|
42
|
+
"o1": (0.0150, 0.0600),
|
|
43
|
+
"o1-mini": (0.0030, 0.0120),
|
|
44
|
+
"o1-preview": (0.0150, 0.0600),
|
|
45
|
+
"o3-mini": (0.0011, 0.0044),
|
|
46
|
+
|
|
47
|
+
# ── Anthropic ──────────────────────────────────────────────────────────
|
|
48
|
+
"claude-opus-4-6": (0.0150, 0.0750),
|
|
49
|
+
"claude-sonnet-4-6": (0.0030, 0.0150),
|
|
50
|
+
"claude-haiku-4-5-20251001": (0.0008, 0.0040),
|
|
51
|
+
"claude-3-5-sonnet-20241022": (0.0030, 0.0150),
|
|
52
|
+
"claude-3-5-sonnet-latest": (0.0030, 0.0150),
|
|
53
|
+
"claude-3-5-haiku-20241022": (0.0008, 0.0040),
|
|
54
|
+
"claude-3-5-haiku-latest": (0.0008, 0.0040),
|
|
55
|
+
"claude-3-opus-20240229": (0.0150, 0.0750),
|
|
56
|
+
"claude-3-sonnet-20240229": (0.0030, 0.0150),
|
|
57
|
+
"claude-3-haiku-20240307": (0.00025, 0.00125),
|
|
58
|
+
|
|
59
|
+
# ── Groq (LPU inference of OSS models, OpenAI-compatible API) ─────────
|
|
60
|
+
"llama-3.3-70b-versatile": (0.00059, 0.00079),
|
|
61
|
+
"llama-3.1-70b-versatile": (0.00059, 0.00079),
|
|
62
|
+
"llama-3.1-8b-instant": (0.00005, 0.00008),
|
|
63
|
+
"mixtral-8x7b-32768": (0.00024, 0.00024),
|
|
64
|
+
"gemma2-9b-it": (0.00020, 0.00020),
|
|
65
|
+
|
|
66
|
+
# ── Google Gemini (via OpenAI-compatible endpoint or native) ──────────
|
|
67
|
+
"gemini-1.5-pro": (0.00125, 0.00500),
|
|
68
|
+
"gemini-1.5-flash": (0.000075, 0.000300),
|
|
69
|
+
"gemini-2.0-flash": (0.00010, 0.00040),
|
|
70
|
+
"gemini-2.0-flash-exp": (0.00010, 0.00040),
|
|
71
|
+
|
|
72
|
+
# ── OpenRouter (passthrough; varies by upstream model) ────────────────
|
|
73
|
+
# OpenRouter prefixes upstream IDs as "<provider>/<model>". Add the
|
|
74
|
+
# specific routes you use; we list a few common ones as starters.
|
|
75
|
+
"openai/gpt-4o": (0.0025, 0.0100),
|
|
76
|
+
"openai/gpt-4o-mini": (0.00015, 0.00060),
|
|
77
|
+
"anthropic/claude-3.5-sonnet": (0.0030, 0.0150),
|
|
78
|
+
"google/gemini-2.0-flash-001": (0.00010, 0.00040),
|
|
79
|
+
"meta-llama/llama-3.3-70b-instruct": (0.00012, 0.00030),
|
|
80
|
+
|
|
81
|
+
# ── Ollama (local; cost is electricity, not API spend) ────────────────
|
|
82
|
+
# Recorded as zero so cost_lt_usd graders pass naturally for local runs.
|
|
83
|
+
"llama3.1": (0.0, 0.0),
|
|
84
|
+
"llama3.2": (0.0, 0.0),
|
|
85
|
+
"qwen2.5": (0.0, 0.0),
|
|
86
|
+
"mistral": (0.0, 0.0),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Track which models we've already warned about so we don't spam logs across a
|
|
91
|
+
# large suite. Keyed per-process; reset by tests via `_reset_warnings()`.
|
|
92
|
+
_warned_models: set[str] = set()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def register_prices(prices: PriceTable) -> None:
|
|
96
|
+
"""Merge `prices` into the global default table.
|
|
97
|
+
|
|
98
|
+
Useful at the top of a suite file::
|
|
99
|
+
|
|
100
|
+
from agentprdiff.adapters import register_prices
|
|
101
|
+
register_prices({"my-finetune-v3": (0.0009, 0.0018)})
|
|
102
|
+
"""
|
|
103
|
+
DEFAULT_PRICES.update(prices)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def estimate_cost_usd(
|
|
107
|
+
model: str,
|
|
108
|
+
*,
|
|
109
|
+
prompt_tokens: int,
|
|
110
|
+
completion_tokens: int,
|
|
111
|
+
prices: PriceTable | None = None,
|
|
112
|
+
) -> float:
|
|
113
|
+
"""Compute USD cost for a single LLM call from token counts.
|
|
114
|
+
|
|
115
|
+
Returns 0.0 and warns once per process if the model isn't in the table.
|
|
116
|
+
"""
|
|
117
|
+
table: PriceTable = prices if prices is not None else DEFAULT_PRICES
|
|
118
|
+
entry = table.get(model)
|
|
119
|
+
if entry is None:
|
|
120
|
+
if model not in _warned_models:
|
|
121
|
+
_warned_models.add(model)
|
|
122
|
+
warnings.warn(
|
|
123
|
+
f"[agentprdiff] no pricing entry for model {model!r}; cost_usd will be "
|
|
124
|
+
"recorded as 0.0. Pass prices={...} to instrument_client(...) or call "
|
|
125
|
+
"agentprdiff.adapters.register_prices({...}) to fix.",
|
|
126
|
+
RuntimeWarning,
|
|
127
|
+
stacklevel=3,
|
|
128
|
+
)
|
|
129
|
+
return 0.0
|
|
130
|
+
in_price, out_price = entry
|
|
131
|
+
return (prompt_tokens * in_price + completion_tokens * out_price) / 1000.0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _reset_warnings() -> None:
|
|
135
|
+
"""Test helper — clear the per-process warning memo."""
|
|
136
|
+
_warned_models.clear()
|
|
@@ -3,12 +3,27 @@
|
|
|
3
3
|
We deliberately keep this ultra-simple for v0.1: the user points at a python
|
|
4
4
|
file path (or module) and we import it; every module-level `Suite` instance
|
|
5
5
|
is a suite to run.
|
|
6
|
+
|
|
7
|
+
Import-path setup: when we exec the suite file, we insert two directories
|
|
8
|
+
onto ``sys.path``:
|
|
9
|
+
|
|
10
|
+
1. The suite file's own parent directory — for sibling helpers, e.g. a
|
|
11
|
+
``suite.py`` next to a ``stubs.py``.
|
|
12
|
+
2. The current working directory — typically the project root from which
|
|
13
|
+
``agentprdiff record`` was invoked. This lets the suite import the
|
|
14
|
+
adopter's own modules (``from agent.agent import ...``,
|
|
15
|
+
``from config import ...``, ``from suites._eval_agent import ...``)
|
|
16
|
+
without forcing every adopter to manipulate ``sys.path`` themselves.
|
|
17
|
+
|
|
18
|
+
Both insertions are reversed after exec so the runner's environment doesn't
|
|
19
|
+
leak suite-specific paths into subsequent loads.
|
|
6
20
|
"""
|
|
7
21
|
|
|
8
22
|
from __future__ import annotations
|
|
9
23
|
|
|
10
24
|
import contextlib
|
|
11
25
|
import importlib.util
|
|
26
|
+
import os
|
|
12
27
|
import sys
|
|
13
28
|
from pathlib import Path
|
|
14
29
|
|
|
@@ -30,13 +45,23 @@ def load_suites(path: str | Path) -> list[Suite]:
|
|
|
30
45
|
if spec is None or spec.loader is None: # pragma: no cover
|
|
31
46
|
raise ImportError(f"could not load suite file: {p}")
|
|
32
47
|
module = importlib.util.module_from_spec(spec)
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
|
|
49
|
+
# Make both the suite file's directory and the cwd importable. We track
|
|
50
|
+
# what we actually inserted so we only remove our own contributions.
|
|
51
|
+
parent_dir = str(p.parent)
|
|
52
|
+
cwd = os.getcwd()
|
|
53
|
+
inserted: list[str] = []
|
|
54
|
+
for entry in (parent_dir, cwd):
|
|
55
|
+
if entry and entry not in sys.path:
|
|
56
|
+
sys.path.insert(0, entry)
|
|
57
|
+
inserted.append(entry)
|
|
58
|
+
|
|
35
59
|
try:
|
|
36
60
|
spec.loader.exec_module(module)
|
|
37
61
|
finally:
|
|
38
|
-
|
|
39
|
-
|
|
62
|
+
for entry in inserted:
|
|
63
|
+
with contextlib.suppress(ValueError):
|
|
64
|
+
sys.path.remove(entry)
|
|
40
65
|
|
|
41
66
|
suites = [v for v in vars(module).values() if isinstance(v, Suite)]
|
|
42
67
|
if not suites:
|
agentprdiff-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `agentprdiff` are documented in this file. Originally
|
|
4
|
-
prototyped under the name `tracediff`; renamed before first public release.
|
|
5
|
-
|
|
6
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
|
-
|
|
9
|
-
## [0.1.0] — 2026-04-22
|
|
10
|
-
|
|
11
|
-
Initial public release.
|
|
12
|
-
|
|
13
|
-
### Added
|
|
14
|
-
|
|
15
|
-
- Core `Suite` / `Case` / `Trace` model for defining agent regression tests.
|
|
16
|
-
- Deterministic graders: `contains`, `contains_any`, `regex_match`, `tool_called`,
|
|
17
|
-
`tool_sequence`, `output_length_lt`, `latency_lt_ms`, `cost_lt_usd`,
|
|
18
|
-
`no_tool_called`.
|
|
19
|
-
- Semantic grader (`semantic`) with a pluggable `judge` callable and built-in
|
|
20
|
-
fake judge for CI environments without API keys.
|
|
21
|
-
- Baseline store (JSON files under `.agentprdiff/baselines/`) designed to be
|
|
22
|
-
committed to version control.
|
|
23
|
-
- Trace diff engine producing a structured `TraceDelta` (assertion pass/fail
|
|
24
|
-
changes, cost delta, latency delta, tool-call sequence changes, output
|
|
25
|
-
change).
|
|
26
|
-
- CLI: `agentprdiff init`, `agentprdiff record`, `agentprdiff check`, `agentprdiff diff`.
|
|
27
|
-
- Rich-formatted terminal reporter and machine-readable JSON reporter for CI.
|
|
28
|
-
- Quickstart example with a mock agent that runs without any API keys.
|
|
29
|
-
- Pytest test suite covering graders, runner, differ, store, and CLI smoke.
|
|
30
|
-
- GitHub Actions CI workflow.
|
|
31
|
-
|
|
32
|
-
### Known limitations
|
|
33
|
-
|
|
34
|
-
- Only a manual instrumentation API for provider SDKs is shipped in 0.1.0.
|
|
35
|
-
Drop-in wrappers for OpenAI / Anthropic / Vercel AI SDK are planned for 0.2.
|
|
36
|
-
- The semantic grader's built-in judge supports OpenAI and Anthropic via user-
|
|
37
|
-
supplied API keys; hosted judge endpoints are not yet offered.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|