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.

New here? Jump to Quick start — it takes about a minute. Looking for the bigger picture? See the guides on testing AI agents, CI and more.

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

Quick start#

  1. 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
  2. Replay. Run the same tests again. Every call is served from the cassette; nothing leaves your machine.
    retake replay -- pytest
  3. Commit the cassettes/ directory, and use retake replay in 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#

ModeCall already recordedNew callUse it for
record— (each session starts empty)Forwarded and savedCreating or refreshing cassettes
replayServed from the cassetteFails with a diffCI, and any run that must not hit the network
autoServed from the cassetteForwarded and appendedDay-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:

UpstreamForwards toVariable set for your command
openaihttps://api.openai.comOPENAI_BASE_URL=http://127.0.0.1:<port>/openai/v1
anthropichttps://api.anthropic.comANTHROPIC_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#

Redaction is pattern-based. It can't recognise personal data in prompts or unusual token formats — review cassettes before committing them.

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": [ ... ] }
    }
  }
]
}

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]
OptionDefaultDescription
--cassettes <DIR>cassettesCassette directory. Env: RETAKE_CASSETTES
-s, --session <NAME>defaultSession 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.1Address to listen on
--upstream <NAME=URL>openai, anthropicAdd or override an upstream. Repeatable.
--ignore <FIELD>—Dotted JSON path ignored when matching. Repeatable.
--timing <fast|realtime>fastPacing of replayed streams
--record-timingoffStore event timing while recording
--record-errorsoffAlso record 429 and 5xx responses
-v, -vv, -q—More or less logging. RUST_LOG overrides.

Exit codes

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.