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.
Files changed (24) hide show
  1. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/.gitignore +4 -4
  2. agentprdiff-0.2.0/CHANGELOG.md +103 -0
  3. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/PKG-INFO +32 -2
  4. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/README.md +31 -1
  5. agentprdiff-0.2.0/examples/regression-tour/README.md +140 -0
  6. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/pyproject.toml +1 -1
  7. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/__init__.py +17 -1
  8. agentprdiff-0.2.0/src/agentprdiff/adapters/__init__.py +43 -0
  9. agentprdiff-0.2.0/src/agentprdiff/adapters/anthropic.py +191 -0
  10. agentprdiff-0.2.0/src/agentprdiff/adapters/openai.py +343 -0
  11. agentprdiff-0.2.0/src/agentprdiff/adapters/pricing.py +136 -0
  12. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/loader.py +29 -4
  13. agentprdiff-0.1.0/CHANGELOG.md +0 -37
  14. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/LICENSE +0 -0
  15. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/examples/quickstart/README.md +0 -0
  16. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/cli.py +0 -0
  17. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/core.py +0 -0
  18. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/differ.py +0 -0
  19. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/graders/__init__.py +0 -0
  20. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/graders/deterministic.py +0 -0
  21. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/graders/semantic.py +0 -0
  22. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/reporters.py +0 -0
  23. {agentprdiff-0.1.0 → agentprdiff-0.2.0}/src/agentprdiff/runner.py +0 -0
  24. {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
- # agentguard runtime
30
- # Users SHOULD commit .agentguard/baselines; they should NOT commit run artifacts.
31
- .agentguard/runs/
32
- .agentguard/cache/
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.1.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
  [![Python](https://img.shields.io/pypi/pyversions/agentprdiff.svg)](https://pypi.org/project/agentprdiff/)
51
51
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./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
- `agentprdiff` doesn't monkey-patch anything. Your agent returns `(output, Trace)`:
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
  [![Python](https://img.shields.io/pypi/pyversions/agentprdiff.svg)](https://pypi.org/project/agentprdiff/)
14
14
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./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
- `agentprdiff` doesn't monkey-patch anything. Your agent returns `(output, Trace)`:
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.1.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.1.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
- # Ensure the file's own directory is importable (for relative helpers).
34
- sys.path.insert(0, str(p.parent))
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
- with contextlib.suppress(ValueError):
39
- sys.path.remove(str(p.parent))
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:
@@ -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