llm-cassette 0.1.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.
- llm_cassette-0.1.0/.github/workflows/ci.yml +34 -0
- llm_cassette-0.1.0/.github/workflows/release.yml +56 -0
- llm_cassette-0.1.0/.gitignore +36 -0
- llm_cassette-0.1.0/.pre-commit-config.yaml +18 -0
- llm_cassette-0.1.0/CHANGELOG.md +15 -0
- llm_cassette-0.1.0/LICENSE +21 -0
- llm_cassette-0.1.0/PKG-INFO +301 -0
- llm_cassette-0.1.0/README.md +268 -0
- llm_cassette-0.1.0/examples/cassettes/weather.jsonl +4 -0
- llm_cassette-0.1.0/examples/openai_agent.py +138 -0
- llm_cassette-0.1.0/examples/test_openai_agent.py +36 -0
- llm_cassette-0.1.0/pyproject.toml +78 -0
- llm_cassette-0.1.0/src/llm_cassette/__init__.py +33 -0
- llm_cassette-0.1.0/src/llm_cassette/__main__.py +6 -0
- llm_cassette-0.1.0/src/llm_cassette/anthropic.py +413 -0
- llm_cassette-0.1.0/src/llm_cassette/cassette.py +554 -0
- llm_cassette-0.1.0/src/llm_cassette/cli.py +70 -0
- llm_cassette-0.1.0/src/llm_cassette/diff.py +225 -0
- llm_cassette-0.1.0/src/llm_cassette/errors.py +38 -0
- llm_cassette-0.1.0/src/llm_cassette/format.py +166 -0
- llm_cassette-0.1.0/src/llm_cassette/normalise.py +143 -0
- llm_cassette-0.1.0/src/llm_cassette/openai.py +391 -0
- llm_cassette-0.1.0/src/llm_cassette/py.typed +0 -0
- llm_cassette-0.1.0/src/llm_cassette/pytest_plugin.py +237 -0
- llm_cassette-0.1.0/src/llm_cassette/tool.py +72 -0
- llm_cassette-0.1.0/src/llm_cassette/types.py +11 -0
- llm_cassette-0.1.0/tests/test_anthropic.py +481 -0
- llm_cassette-0.1.0/tests/test_cassette.py +1026 -0
- llm_cassette-0.1.0/tests/test_diff.py +341 -0
- llm_cassette-0.1.0/tests/test_normalise.py +142 -0
- llm_cassette-0.1.0/tests/test_openai.py +665 -0
- llm_cassette-0.1.0/tests/test_pytest_plugin.py +444 -0
- llm_cassette-0.1.0/tests/test_tool.py +75 -0
- llm_cassette-0.1.0/tests/test_version.py +7 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
ci:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v6
|
|
20
|
+
- uses: actions/setup-python@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
- name: Install
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip
|
|
26
|
+
python -m pip install -e ".[dev,openai,anthropic]"
|
|
27
|
+
- name: Ruff
|
|
28
|
+
run: |
|
|
29
|
+
ruff check .
|
|
30
|
+
ruff format --check .
|
|
31
|
+
- name: Mypy
|
|
32
|
+
run: mypy
|
|
33
|
+
- name: Pytest
|
|
34
|
+
run: pytest
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
actions: write
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
|
+
- uses: actions/setup-python@v6
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
- name: Install build tools
|
|
23
|
+
run: python -m pip install --upgrade pip build twine
|
|
24
|
+
- name: Check tag matches package version
|
|
25
|
+
run: |
|
|
26
|
+
version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
|
|
27
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
28
|
+
if [ "$tag" != "$version" ]; then
|
|
29
|
+
echo "tag ${GITHUB_REF_NAME} does not match package version ${version}" >&2
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
- name: Build
|
|
33
|
+
run: python -m build
|
|
34
|
+
- name: Check distributions
|
|
35
|
+
run: twine check dist/*
|
|
36
|
+
- uses: actions/upload-artifact@v7
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
publish:
|
|
42
|
+
needs: build
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
environment:
|
|
45
|
+
name: pypi
|
|
46
|
+
url: https://pypi.org/p/llm-cassette
|
|
47
|
+
permissions:
|
|
48
|
+
id-token: write
|
|
49
|
+
actions: read
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/download-artifact@v8
|
|
52
|
+
with:
|
|
53
|
+
name: dist
|
|
54
|
+
path: dist
|
|
55
|
+
- name: Publish to PyPI
|
|
56
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Byte-compiled files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
*.egg
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Tools
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
.coverage.*
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
.nox/
|
|
27
|
+
|
|
28
|
+
# Secrets
|
|
29
|
+
.env
|
|
30
|
+
.env.*
|
|
31
|
+
|
|
32
|
+
# Editors and OS
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
*.swp
|
|
36
|
+
.DS_Store
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.17.1
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff-check
|
|
6
|
+
args: [--fix]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
|
|
9
|
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
10
|
+
rev: v2.4.0
|
|
11
|
+
hooks:
|
|
12
|
+
- id: mypy
|
|
13
|
+
# Replace the hook default `--ignore-missing-imports`.
|
|
14
|
+
args: []
|
|
15
|
+
additional_dependencies:
|
|
16
|
+
- pytest>=9.1
|
|
17
|
+
- openai>=1.66,<2
|
|
18
|
+
- anthropic>=2,<3
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-21
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- JSONL cassettes with `once`, `replay`, `record`, and `none`, ordered matching, request normalisation, and secret redaction.
|
|
10
|
+
- OpenAI Python SDK 1.x adapter for `chat.completions.create` and `responses.create`, including streams.
|
|
11
|
+
- Anthropic Python SDK 2.x adapter for `messages.create` and `messages.stream`.
|
|
12
|
+
- `@tool` for recording plain Python functions.
|
|
13
|
+
- Pytest plugin with `@pytest.mark.cassette`, the `cassette` fixture, and `--replay-mode`.
|
|
14
|
+
- `llm-cassette diff` for reviewing re-recorded cassettes.
|
|
15
|
+
- An offline OpenAI weather agent in `examples/`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Toluwase Babalola
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: llm-cassette
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Record and replay LLM agent SDK and tool calls for offline pytest.
|
|
5
|
+
Project-URL: Homepage, https://github.com/thbabs/llm-cassette
|
|
6
|
+
Project-URL: Repository, https://github.com/thbabs/llm-cassette
|
|
7
|
+
Project-URL: Issues, https://github.com/thbabs/llm-cassette/issues
|
|
8
|
+
Author-email: Toluwase Babalola <thbabs@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anthropic,cassette,llm,openai,testing
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: anthropic
|
|
23
|
+
Requires-Dist: anthropic<3,>=2; extra == 'anthropic'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=2.4; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit<4.7,>=4.6.2; (python_version < '3.11') and extra == 'dev'
|
|
27
|
+
Requires-Dist: pre-commit>=4.7; (python_version >= '3.11') and extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=9.1; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.17.1; extra == 'dev'
|
|
30
|
+
Provides-Extra: openai
|
|
31
|
+
Requires-Dist: openai<2,>=1.66; extra == 'openai'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# llm-cassette
|
|
35
|
+
|
|
36
|
+
[](https://github.com/thbabs/llm-cassette/actions/workflows/ci.yml)
|
|
37
|
+
|
|
38
|
+
Record and replay LLM and tool calls as readable JSONL cassettes.
|
|
39
|
+
|
|
40
|
+
**Status: 0.1.0.** `cassette()` records and replays `llm_call` and `tool_call` events through `Cassette.record_or_replay`, including async callables. The optional `openai` extra patches the OpenAI Python SDK 1.x, and the optional `anthropic` extra patches the Anthropic Python SDK 2.x, while a cassette is open. `@tool` records plain Python functions. The pytest plugin opens a cassette for a test that uses `@pytest.mark.cassette` or the `cassette` fixture. `llm-cassette diff` compares two cassettes.
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import pytest
|
|
46
|
+
from llm_cassette import tool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@tool
|
|
50
|
+
def get_weather(city: str) -> dict[str, int]:
|
|
51
|
+
return {"temp": 18}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.cassette
|
|
55
|
+
def test_paris() -> None:
|
|
56
|
+
assert get_weather("Paris") == {"temp": 18}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Put that in `tests/test_agent.py`. The first run writes `tests/cassettes/test_agent/test_paris.jsonl`. The next run replays that file and does not call `get_weather`. A different request fails the test with `CassetteMismatchError`.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
llm-cassette diff old.jsonl new.jsonl
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The command exits 0 when the cassettes match and 1 when they do not. A missing or invalid file exits 2.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from llm_cassette import cassette
|
|
71
|
+
|
|
72
|
+
request = {
|
|
73
|
+
"provider": "openai",
|
|
74
|
+
"request": {
|
|
75
|
+
"model": "gpt-4o",
|
|
76
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
with cassette("cassettes/example.jsonl", mode="once") as session:
|
|
81
|
+
response = session.record_or_replay(
|
|
82
|
+
"llm_call",
|
|
83
|
+
request,
|
|
84
|
+
lambda: {"content": "hello"},
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`cassette(path, mode=...)` is a sync context manager, an async context manager, and a decorator. Inside the block, `record_or_replay(kind, request, call_fn)` runs the call or returns the recorded JSON. Await it when `call_fn` is async. Tool calls use `kind="tool_call"` with `name` and `args`; the callable's return value is the `result`. `current_cassette()` returns the session active in this context.
|
|
89
|
+
|
|
90
|
+
| Mode | What it does |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `once` | Record when the file is missing, and replay it otherwise. |
|
|
93
|
+
| `replay` | Replay the file. A missing file raises `CassetteNotFoundError`. |
|
|
94
|
+
| `record` | Call through and overwrite the file when the block finishes successfully. |
|
|
95
|
+
| `none` | Call through and leave the file untouched. |
|
|
96
|
+
|
|
97
|
+
Replay walks the file in order and matches a hash of the normalised request. `strict=True` compares the normalised requests and raises `CassetteMismatchError` if a recorded interaction was not used. Pass `matcher` to replace the comparison. On a miss, the error shows the expected and received normalised requests.
|
|
98
|
+
|
|
99
|
+
Before hashing, matching, or writing, volatile fields (`id`, timestamps, and request ids) are removed. Secret fields (API keys, `Authorization`, and similar names) are replaced with `<redacted>`. `max_tokens` is kept. Recorded values come back as JSON, with tuples turned into lists, so a recording and a later replay match.
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{"type": "header", "format_version": 1}
|
|
103
|
+
{"type": "llm_call", "provider": "openai", "request": {"model": "gpt-4o"}, "response": {"content": "hello"}}
|
|
104
|
+
{"type": "tool_call", "name": "get_weather", "args": {"city": "Paris"}, "result": {"temp": 18}}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## OpenAI
|
|
108
|
+
|
|
109
|
+
`pip install "llm-cassette[openai]"` installs the OpenAI Python SDK 1.x. `install()` patches `chat.completions.create` and `responses.create` on the sync and async clients while a cassette is open, and restores the original methods when it exits. Replay rebuilds `ChatCompletion` and `Response` objects, and re-emits streams as the SDK's stream objects. The API key is not written to the cassette.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from openai import OpenAI
|
|
113
|
+
|
|
114
|
+
from llm_cassette import cassette
|
|
115
|
+
from llm_cassette.openai import install
|
|
116
|
+
|
|
117
|
+
install()
|
|
118
|
+
|
|
119
|
+
with cassette("cassettes/hello.jsonl", mode="once"):
|
|
120
|
+
completion = OpenAI().chat.completions.create(
|
|
121
|
+
model="gpt-4o-mini",
|
|
122
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print(completion.choices[0].message.content)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Anthropic
|
|
129
|
+
|
|
130
|
+
`pip install "llm-cassette[anthropic]"` installs the Anthropic Python SDK 2.x. `install()` patches `messages.create` and `messages.stream` on the sync and async clients while a cassette is open, and restores the original methods when it exits. `create(stream=True)` replays as the SDK's `Stream` or `AsyncStream` of raw message events. `messages.stream()` replays as `MessageStream` or `AsyncMessageStream`, including `text_stream` and `get_final_message()`. That helper is recorded when its context manager is entered, which is when the SDK sends the request. Other calls replay as `anthropic.types.Message` objects. The API key is not written to the cassette.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from anthropic import Anthropic
|
|
134
|
+
|
|
135
|
+
from llm_cassette import cassette
|
|
136
|
+
from llm_cassette.anthropic import install
|
|
137
|
+
|
|
138
|
+
install()
|
|
139
|
+
|
|
140
|
+
with cassette("cassettes/hello.jsonl", mode="once"):
|
|
141
|
+
message = Anthropic().messages.create(
|
|
142
|
+
model="claude-sonnet-5",
|
|
143
|
+
max_tokens=32,
|
|
144
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
print(message.content[0].text)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Tools
|
|
151
|
+
|
|
152
|
+
`@tool` records a function's name, its bound arguments (including defaults), and its JSON result as a `tool_call`. Replay returns that result and does not call the function. A different argument raises `CassetteMismatchError`. Async functions work the same way. With no cassette open, the function runs as usual. Results follow the cassette rules: they must be JSON, tuples become lists, and secret fields are redacted.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from llm_cassette import cassette, tool
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@tool
|
|
159
|
+
def get_weather(city: str, unit: str = "c") -> dict[str, str]:
|
|
160
|
+
return {"city": city, "unit": unit}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
with cassette("cassettes/weather.jsonl", mode="once"):
|
|
164
|
+
weather = get_weather("Paris")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`get_weather("Paris")` and `get_weather(city="Paris", unit="c")` match, because the recorded arguments include the default `unit`.
|
|
168
|
+
|
|
169
|
+
## pytest
|
|
170
|
+
|
|
171
|
+
llm-cassette registers a pytest plugin. Opt in with `@pytest.mark.cassette` or the `cassette` fixture. The cassette sits next to the test module and is named after the test:
|
|
172
|
+
|
|
173
|
+
`tests/cassettes/test_agent/test_paris.jsonl`
|
|
174
|
+
|
|
175
|
+
A class method is nested under the class name. A parametrised test includes its id. Characters that are unsafe in a file name are percent-encoded, and `%` is encoded too, so each id gets its own file.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
import pytest
|
|
179
|
+
|
|
180
|
+
from llm_cassette import tool
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@tool
|
|
184
|
+
def get_weather(city: str) -> dict[str, str]:
|
|
185
|
+
return {"city": city}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.cassette
|
|
189
|
+
def test_paris() -> None:
|
|
190
|
+
assert get_weather("Paris") == {"city": "Paris"}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`@pytest.mark.cassette("weather", mode="replay")` uses `tests/cassettes/test_agent/weather.jsonl` and forces `replay` for that test. On a parametrised test the parameter id is added to that name. The `cassette` fixture is the open `Cassette`.
|
|
194
|
+
|
|
195
|
+
`--replay-mode` chooses `once` (the default), `replay`, `record`, or `none` for every opted-in test. `replay_mode` in a pytest config file is the same setting. The command line overrides the config file, and `mode=` on the marker overrides both.
|
|
196
|
+
|
|
197
|
+
| `--replay-mode` | What it does |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| `once` | Record when the file is missing, and replay it otherwise. |
|
|
200
|
+
| `replay` | Replay the file. A missing file raises `CassetteNotFoundError`. |
|
|
201
|
+
| `record` | Call through and overwrite the file when the test passes. |
|
|
202
|
+
| `none` | Call through and leave the file untouched. |
|
|
203
|
+
|
|
204
|
+
These are the same modes as `cassette()`. A request that does not match the recording fails the test with `CassetteMismatchError`, and the failure shows the expected and received requests. The plugin leaves `strict` off, so a test that does not replay every recorded interaction still passes. It does not call `install()` for the OpenAI or Anthropic adapters; those patches still have to be installed for SDK calls to be recorded.
|
|
205
|
+
|
|
206
|
+
`replay` is the mode that fails when a cassette is missing. `none` does not: it never reads the file.
|
|
207
|
+
|
|
208
|
+
```toml
|
|
209
|
+
[tool.pytest.ini_options]
|
|
210
|
+
replay_mode = "replay"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
pytest --replay-mode=replay
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Example
|
|
218
|
+
|
|
219
|
+
`examples/openai_agent.py` is a two-step OpenAI agent. The model requests `get_weather`, the cassette supplies the temperature, and the model answers. Replay reads `examples/cassettes/weather.jsonl`. No API key and no network:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
python examples/openai_agent.py
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`examples/test_openai_agent.py` runs that replay under pytest. CI installs the OpenAI extra and does not set a key.
|
|
226
|
+
|
|
227
|
+
## Diff
|
|
228
|
+
|
|
229
|
+
`llm-cassette diff A.jsonl B.jsonl` compares interactions in order. It reports events that were added, removed, or changed. A changed `llm_call` shows a unified diff of the normalised request, the response, or both. A tool call's result is the response. Event numbers start at 1 and skip the header line.
|
|
230
|
+
|
|
231
|
+
Requests are normalised the same way recording is: volatile ids and timestamps are removed, and secret fields are redacted. Responses keep recorded ids and timestamps, because replay rebuilds SDK objects from them, and secret fields are redacted there too. Two files can differ on disk and still compare equal when the only request changes are volatile fields.
|
|
232
|
+
|
|
233
|
+
```text
|
|
234
|
+
--- old.jsonl
|
|
235
|
+
+++ new.jsonl
|
|
236
|
+
|
|
237
|
+
1 added, 1 changed
|
|
238
|
+
|
|
239
|
+
changed llm_call 1 (openai)
|
|
240
|
+
request:
|
|
241
|
+
--- old.jsonl request
|
|
242
|
+
+++ new.jsonl request
|
|
243
|
+
@@ -1,6 +1,6 @@
|
|
244
|
+
{
|
|
245
|
+
"provider": "openai",
|
|
246
|
+
"request": {
|
|
247
|
+
- "model": "gpt-4o"
|
|
248
|
+
+ "model": "gpt-4o-mini"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
added tool_call 2 (get_weather)
|
|
253
|
+
request:
|
|
254
|
+
{
|
|
255
|
+
"args": {
|
|
256
|
+
"city": "Paris"
|
|
257
|
+
},
|
|
258
|
+
"name": "get_weather"
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Exit 0 when that report says the cassettes are identical. Exit 1 when it lists changes. Exit 2 when a path is missing or the file is not a cassette. The command only reads the files.
|
|
263
|
+
|
|
264
|
+
## Limitations
|
|
265
|
+
|
|
266
|
+
- The OpenAI adapter covers `chat.completions.create` and `responses.create` on the Python SDK 1.x. The Anthropic adapter covers `messages.create` and `messages.stream` on the Python SDK 2.x. Other providers, other methods, and raw HTTP are not recorded.
|
|
267
|
+
- SDK calls are recorded only after `install()`. The pytest plugin does not call it.
|
|
268
|
+
- One cassette is not safe for concurrent use. An async `record_or_replay` has to be awaited.
|
|
269
|
+
- Recorded values have to be JSON. Tuples become lists. Datetimes, bytes, and other objects are rejected.
|
|
270
|
+
- Secret redaction matches field names such as `api_key`, `authorization`, and `token`. A secret written in ordinary text is stored as text. Read a cassette before committing it.
|
|
271
|
+
- Volatile-field removal matches key names. A request field named `id` is dropped wherever it sits, including an OpenAI tool call's `id`. `tool_call_id` is kept. Response ids and timestamps are kept. `llm-cassette diff` follows those rules: a response id change shows up, and a request id change does not.
|
|
272
|
+
- Matching walks the file in order. The same request twice is two interactions. The default comparison is a hash of the normalised request, not a comparison of the response.
|
|
273
|
+
- The pytest plugin leaves `strict` off, so a test can pass without replaying every recorded interaction. `replay` fails when the file is missing. `none` does not read the file.
|
|
274
|
+
|
|
275
|
+
## Roadmap
|
|
276
|
+
|
|
277
|
+
- Adapters for more SDKs and methods.
|
|
278
|
+
- A way to choose which field names are secret or volatile.
|
|
279
|
+
- A pytest option that installs the OpenAI and Anthropic adapters.
|
|
280
|
+
- An opt-in strict default, so unused interactions fail once a suite asks for that.
|
|
281
|
+
|
|
282
|
+
## Development
|
|
283
|
+
|
|
284
|
+
Requires Python 3.10 or newer.
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
python -m venv .venv
|
|
288
|
+
source .venv/bin/activate
|
|
289
|
+
python -m pip install -e ".[dev,openai,anthropic]"
|
|
290
|
+
pre-commit install
|
|
291
|
+
ruff check .
|
|
292
|
+
ruff format --check .
|
|
293
|
+
mypy
|
|
294
|
+
pytest
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
CI runs ruff, mypy, and pytest on Python 3.10, 3.11, 3.12, and 3.13. That includes `examples/test_openai_agent.py`.
|
|
298
|
+
|
|
299
|
+
## Releasing
|
|
300
|
+
|
|
301
|
+
Pushing a `v*` tag runs `.github/workflows/release.yml`. The tag has to match `pyproject.toml`: `v0.1.0` publishes version `0.1.0`. The workflow builds an sdist and a wheel and publishes them to PyPI with trusted publishing. It does not use an API token.
|