retake documentation
retake records the HTTP calls your code makes to LLM APIs and replays them in later runs. Your tests get real model output with the speed, cost and determinism of a mock. It runs as a small local proxy, so it works with any language and any SDK, without code changes.
Installation#
macOS and Linux
curl -fsSL https://zecrari.github.io/retake/install.sh | sh
Installs to ~/.local/bin. Set RETAKE_INSTALL_DIR to change that, or RETAKE_VERSION=v0.1.0 to pin a version. The script verifies the SHA-256 checksum of the download.
Windows
irm https://zecrari.github.io/retake/install.ps1 | iex
Other options
- Download an archive from GitHub Releases (Linux x86_64/arm64, macOS Intel/Apple silicon, Windows x86_64).
- Build from source with Rust 1.88+:
cargo install --git https://github.com/Zecrari/retake --locked - In GitHub Actions:
uses: Zecrari/retake@v0(see CI setup).
Quick start#
- Record. Run your tests through retake with a real API key. Calls go to the real API and are saved to
cassettes/default.json.OPENAI_API_KEY=sk-... retake record -- pytest - Replay. Run the same tests again. Every call is served from the cassette; nothing leaves your machine.
retake replay -- pytest - Commit the
cassettes/directory, and useretake replayin CI.
Anything after -- is the command to run: npm test, go test ./..., python app.py, a shell script. retake starts a proxy on a free port, runs the command with the right environment variables, and prints a summary when it exits:
retake: 12 replayed, 0 recorded, 0 unmatched
Modes#
| Mode | Call already recorded | New call | Use it for |
|---|---|---|---|
record | — (each session starts empty) | Forwarded and saved | Creating or refreshing cassettes |
replay | Served from the cassette | Fails with a diff | CI, and any run that must not hit the network |
auto | Served from the cassette | Forwarded and appended | Day-to-day development |
In replay, the run exits with code 1 if any call had no recording, even if your tests passed — so a test that silently swallowed the error can't hide it.
Responses with status 429 or 5xx are passed through but not recorded, so SDK retries don't end up in cassettes. Use --record-errors if you want to test error handling with real error responses.
Sessions#
A session is one cassette file. By default every call goes to the default session (cassettes/default.json); change it with --session.
For larger suites, give each test its own cassette by sending the x-retake-session header. retake strips the header before forwarding. Names may contain letters, digits, -, _, . and /, and slashes create folders.
pytest
import re, pytest
from openai import OpenAI
@pytest.fixture
def client(request):
session = re.sub(r"[^A-Za-z0-9._/-]", "_", request.node.nodeid.replace("::", "/"))
return OpenAI(default_headers={"x-retake-session": session})
Vitest
import OpenAI from "openai";
import { beforeEach, expect } from "vitest";
let client: OpenAI;
beforeEach(() => {
const name = expect.getState().currentTestName ?? "default";
client = new OpenAI({
defaultHeaders: { "x-retake-session": name.replace(/[^A-Za-z0-9._/-]+/g, "_") },
});
});
Go
client := openai.NewClient(
option.WithHeader("x-retake-session", "billing/"+t.Name()),
)
Matching#
A replayed call matches a recording when the method, upstream, path, query string and body are the same. JSON bodies are compared after sorting object keys, so formatting and key order never matter.
Ignoring volatile fields
If your requests include values that change every run — request IDs, timestamps, random seeds — ignore them with --ignore and a dotted path. Arrays are traversed automatically, so messages.id ignores the id of every message.
retake replay --ignore metadata.request_id --ignore user -- npm test
Repeated and parallel calls
Each recording is used once per run. If your code makes the same call three times, the three recorded responses are replayed in the order they were recorded. Calls that differ are matched independently, so parallel requests work regardless of timing.
When nothing matches
In replay mode, an unmatched call gets a 404 response with error type retake_no_match, and retake prints a diff against the closest unused recording — preferring one to the same endpoint:
no recording in session `default` matches POST /openai/v1/chat/completions (closest: step 1)
--- recorded (step 1)
+++ actual
@@ -2,7 +2,7 @@
"messages": [
{
- "content": "What is the status of order 1042?",
+ "content": "Where is order 1042?",
"role": "user"
retake deliberately uses a 4xx status: the OpenAI and Anthropic SDKs automatically retry 5xx responses, which would turn a clear failure into a slow one.
Streaming#
Server-sent event streams ("stream": true) are passed through to your code live while recording, and stored as complete events — however the bytes arrived on the network. On replay, the events are sent back byte-for-byte.
Replays are instant by default. To reproduce the original pacing — for example to test timeouts or a streaming UI — record with --record-timing and replay with --timing realtime. Timing is off by default because it changes on every recording and makes cassette diffs noisy.
Providers and upstreams#
Requests reach retake at /<upstream>/<path>, and are forwarded to that upstream's base URL. Two upstreams are built in:
| Upstream | Forwards to | Variable set for your command |
|---|---|---|
openai | https://api.openai.com | OPENAI_BASE_URL=http://127.0.0.1:<port>/openai/v1 |
anthropic | https://api.anthropic.com | ANTHROPIC_BASE_URL=http://127.0.0.1:<port>/anthropic |
retake also sets RETAKE_URL, RETAKE_MODE and RETAKE_<NAME>_URL for every upstream. In replay mode it sets placeholder values for OPENAI_API_KEY and ANTHROPIC_API_KEY when they're missing, so SDKs that require a key still start in CI.
OpenAI-compatible APIs
Add any other upstream with --upstream name=url — Ollama, vLLM, LM Studio, Groq, OpenRouter, Together, or your own gateway — and point your client at $RETAKE_<NAME>_URL:
retake record --upstream ollama=http://localhost:11434 -- python app.py
# in app.py: OpenAI(base_url=os.environ["RETAKE_OLLAMA_URL"] + "/v1", api_key="ollama")
--upstream also overrides the built-ins, for example --upstream openai=https://my-gateway.example.com.
Without a wrapped command
Run retake with no command to keep the proxy up until Ctrl+C — useful for a dev server or a test runner you start separately. Pick a fixed port with --port; retake prints the variables to set.
retake replay --port 9000
Secrets#
- Request headers are never stored.
Authorization,x-api-key,x-goog-api-keyand every other header stay out of cassettes. - Bodies are redacted. Strings that look like OpenAI, Anthropic, Google, GitHub, AWS or Slack credentials, bearer tokens or private keys are replaced with
[REDACTED]before saving. Matching applies the same redaction, so replays still match. retake checkvalidates cassettes and scans them for secrets, exiting with code 1 if it finds any.retake check --fixredacts them in place.
Pre-commit hook
With the pre-commit framework:
repos:
- repo: https://github.com/Zecrari/retake
rev: v0.1.0
hooks:
- id: retake-check
CI setup#
GitHub Actions
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: Zecrari/retake@v0 # installs the latest release
- run: retake check
- run: retake replay -- npm test
Pin a version with with: { version: v0.1.0 }. The action verifies the download's checksum and works on Linux, macOS and Windows runners. No API key secret is needed.
Other CI systems
curl -fsSL https://zecrari.github.io/retake/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
retake check
retake replay -- npm test
Cassette format#
Each session is a pretty-printed JSON file. Interactions appear in the order they completed.
{
"retake": 1,
"session": "default",
"interactions": [
{
"request": {
"method": "POST",
"upstream": "openai",
"path": "/v1/chat/completions",
"json": { "model": "gpt-4.1-mini", "messages": [ ... ] }
},
"response": {
"status": 200,
"content_type": "application/json",
"json": { "choices": [ ... ] }
}
}
]
}
- Bodies are stored as
jsonwhen they parse as JSON,textfor other UTF-8, andbase64for binary data such as audio. - Streamed responses have an
eventsarray instead of a body; each event'sdatais the raw SSE text, with an optionalt_msoffset. retakeis the format version. Newer versions of retake will keep reading older cassettes.
Cassettes are meant to be read and reviewed, and you can edit them by hand — for example to shorten a long response.
CLI reference#
retake record [OPTIONS] [-- COMMAND...]
retake replay [OPTIONS] [-- COMMAND...]
retake auto [OPTIONS] [-- COMMAND...]
retake check [PATHS...] [--fix]
| Option | Default | Description |
|---|---|---|
--cassettes <DIR> | cassettes | Cassette directory. Env: RETAKE_CASSETTES |
-s, --session <NAME> | default | Session for requests without the header. Env: RETAKE_SESSION |
-p, --port <PORT> | 0 (any free) | Port to listen on. Env: RETAKE_PORT |
--host <ADDR> | 127.0.0.1 | Address to listen on |
--upstream <NAME=URL> | openai, anthropic | Add or override an upstream. Repeatable. |
--ignore <FIELD> | — | Dotted JSON path ignored when matching. Repeatable. |
--timing <fast|realtime> | fast | Pacing of replayed streams |
--record-timing | off | Store event timing while recording |
--record-errors | off | Also record 429 and 5xx responses |
-v, -vv, -q | — | More or less logging. RUST_LOG overrides. |
Exit codes
- The wrapped command's exit code, if it failed.
1if the command succeeded butreplayhad unmatched calls, orretake checkfound problems.2if retake itself failed (bad arguments, port in use, unreadable cassette).
Troubleshooting#
My calls go straight to the real API
Your client isn't using the base URL variables. Check that you don't pass base_url / baseURL explicitly, or pass os.environ["OPENAI_BASE_URL"]. Run with -v to log every replayed call.
Every replayed call fails to match
Read the diff in the output. Usually a field changes every run — add --ignore for it. If your prompt really changed, re-record with retake record or retake auto.
"recorded interaction(s) were never replayed"
Your code made fewer calls than when it was recorded. That's a warning, not a failure; re-record to tidy the cassette.
Something else?
Open an issue with your retake --version, OS, SDK and the output — but never an API key or an unchecked cassette.