standin
A stand-in for the real LLM in your tests. Record your LLM API calls once, then replay them forever: fast, free, deterministic, and fully offline. One line, any provider.
import standin
with standin.use_cassette("tests/cassettes/summary.json"):
reply = client.chat.completions.create(model="gpt-4o", messages=[...])
# First run: hits the real API and records it.
# Every run after: replayed from disk. No network, no cost, same answer.
Your LLM tests are slow, flaky, and cost money because they hit real APIs. standin makes them deterministic and offline by recording the real HTTP calls once and replaying them after, with the things LLM devs actually need: streaming, tool-calls, secret redaction, and body-aware matching.
Why not just VCR.py?
VCR.py is great, but it's a general HTTP tool. standin is built for LLMs:
- Provider-agnostic, zero wiring. It hooks
httpxunder the hood, so it works with OpenAI, Anthropic, Gemini, Mistral, Cohere, litellm, LangChain, LlamaIndex — anything that sends over httpx. No per-SDK adapters. - Streaming just works. Server-sent event (SSE) responses are recorded and replayed intact.
- Safe to commit. API keys in headers and secret-looking tokens in bodies are redacted automatically, so cassettes can live in a public repo.
- Body-aware matching. Requests match on normalized JSON, so key ordering and formatting noise don't break replays. Repeated identical calls (agent loops) replay in order.
- One-line pytest fixture, with sane auto-named cassettes.
- Clean, typed, extensible core (see ARCHITECTURE.md) — swap the matcher, redactor, or storage backend.
Install
pip install standin
Python 3.9+ and httpx (already a dependency of the major LLM SDKs).
Quickstart
With pytest (recommended)
import pytest
@pytest.mark.standin # cassette auto-named tests/cassettes/test_summarize.json
def test_summarize(standin):
out = summarize("war and peace") # your code that calls an LLM
assert "Napoleon" in out
First run records against the real API; every run after replays from the cassette. Commit the cassette and teammates (and CI) run the test with no keys and no network.
Anywhere (context manager)
import standin
from openai import OpenAI
client = OpenAI()
with standin.use_cassette("tests/cassettes/haiku.json"):
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "haiku about testing"}],
)
Modes
| Mode | Behavior |
|---|---|
once (default) |
Replay if the cassette exists, otherwise record it. |
none |
Replay only. Errors on any unrecorded call — use this in CI. |
all |
Always re-record, ignoring existing interactions. |
new_episodes |
Replay what's recorded, record anything new (great for agent loops). |
In CI, force replay-only for the whole run so a stray live call fails loudly:
STANDIN_MODE=none pytest
What a cassette looks like
Plain, reviewable JSON, secrets already stripped:
{
"version": 1,
"recorded_with": "standin",
"interactions": [
{
"request": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"headers": { "authorization": "[REDACTED]" },
"body": { "json": { "model": "gpt-4o-mini", "messages": [ ] } }
},
"response": { "status_code": 200, "body": { "json": { "choices": [ ] } } }
}
]
}
Extending it
Everything is a small protocol you can replace (see ARCHITECTURE.md):
standin.use_cassette(path, matcher=MyMatcher(), redactor=MyRedactor(), store=MyStore())
-
Matcher — decide when a live request equals a recorded one. Ships with
DefaultMatcher(exact) andFuzzyMatcher(body may drift up to a similarity threshold, so a reworded prompt still replays):from standin import use_cassette, FuzzyMatcher, DefaultRedactor with use_cassette(path, matcher=FuzzyMatcher(DefaultRedactor(), threshold=0.9)): ...
-
Redactor — control what gets scrubbed before writing.
-
CassetteStore — change the on-disk format.
Command line
standin list tests/cassettes/summary.json # one line per interaction
standin show tests/cassettes/summary.json 0 # full request/response
standin stats tests/cassettes/summary.json # counts by method/status
standin scrub tests/cassettes/summary.json # re-run secret redaction in place
Roadmap
- Embedding-based semantic matching (a
Matcheryou drop in;FuzzyMatcheralready covers string-similarity drift today). requests/aiohttpinterceptors (the engine is already transport-neutral).
Contributing
See CONTRIBUTING.md. Run the suite with pytest, lint with ruff, type-check with mypy.
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file standin-0.2.0.tar.gz.
File metadata
- Download URL: standin-0.2.0.tar.gz
- Upload date:
- Size: 25.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4436fbc11fcef774d44a9eca348240fd9cc0affb694c7c3a7ddbdb8cc0212c50
|
|
| MD5 |
6ab06cc4fb3e3fda9b2380c3ebcc6268
|
|
| BLAKE2b-256 |
0890e23e9880263b5e4fbb94e5d2bc3f029b313323efef12ae0de424ce6df284
|
File details
Details for the file standin-0.2.0-py3-none-any.whl.
File metadata
- Download URL: standin-0.2.0-py3-none-any.whl
- Upload date:
- Size: 20.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
148f38a7b316676a5635ec15eb140e478aa61db772d53fe63b3d5a4e19c37cd4
|
|
| MD5 |
c00427e996d6de5e6f2e365027a741c9
|
|
| BLAKE2b-256 |
914217706c6346eb0498bfa08a93d5e14c074361bb6dc9adbf541c01827bd85c
|