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.
Files changed (34) hide show
  1. llm_cassette-0.1.0/.github/workflows/ci.yml +34 -0
  2. llm_cassette-0.1.0/.github/workflows/release.yml +56 -0
  3. llm_cassette-0.1.0/.gitignore +36 -0
  4. llm_cassette-0.1.0/.pre-commit-config.yaml +18 -0
  5. llm_cassette-0.1.0/CHANGELOG.md +15 -0
  6. llm_cassette-0.1.0/LICENSE +21 -0
  7. llm_cassette-0.1.0/PKG-INFO +301 -0
  8. llm_cassette-0.1.0/README.md +268 -0
  9. llm_cassette-0.1.0/examples/cassettes/weather.jsonl +4 -0
  10. llm_cassette-0.1.0/examples/openai_agent.py +138 -0
  11. llm_cassette-0.1.0/examples/test_openai_agent.py +36 -0
  12. llm_cassette-0.1.0/pyproject.toml +78 -0
  13. llm_cassette-0.1.0/src/llm_cassette/__init__.py +33 -0
  14. llm_cassette-0.1.0/src/llm_cassette/__main__.py +6 -0
  15. llm_cassette-0.1.0/src/llm_cassette/anthropic.py +413 -0
  16. llm_cassette-0.1.0/src/llm_cassette/cassette.py +554 -0
  17. llm_cassette-0.1.0/src/llm_cassette/cli.py +70 -0
  18. llm_cassette-0.1.0/src/llm_cassette/diff.py +225 -0
  19. llm_cassette-0.1.0/src/llm_cassette/errors.py +38 -0
  20. llm_cassette-0.1.0/src/llm_cassette/format.py +166 -0
  21. llm_cassette-0.1.0/src/llm_cassette/normalise.py +143 -0
  22. llm_cassette-0.1.0/src/llm_cassette/openai.py +391 -0
  23. llm_cassette-0.1.0/src/llm_cassette/py.typed +0 -0
  24. llm_cassette-0.1.0/src/llm_cassette/pytest_plugin.py +237 -0
  25. llm_cassette-0.1.0/src/llm_cassette/tool.py +72 -0
  26. llm_cassette-0.1.0/src/llm_cassette/types.py +11 -0
  27. llm_cassette-0.1.0/tests/test_anthropic.py +481 -0
  28. llm_cassette-0.1.0/tests/test_cassette.py +1026 -0
  29. llm_cassette-0.1.0/tests/test_diff.py +341 -0
  30. llm_cassette-0.1.0/tests/test_normalise.py +142 -0
  31. llm_cassette-0.1.0/tests/test_openai.py +665 -0
  32. llm_cassette-0.1.0/tests/test_pytest_plugin.py +444 -0
  33. llm_cassette-0.1.0/tests/test_tool.py +75 -0
  34. 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
+ [![CI](https://github.com/thbabs/llm-cassette/actions/workflows/ci.yml/badge.svg)](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.