10-second demo
Install, wrap, and switch between live, record, and replay with one env var.
npm install -D tapedeckimport { openai } from '@ai-sdk/openai';
import { generateText, wrapLanguageModel } from 'ai';
import { cassetteMiddleware } from 'tapedeck';
const model = wrapLanguageModel({
model: openai('gpt-4o'),
middleware: cassetteMiddleware({
mode: process.env.CASSETTE_MODE ?? 'live',
cassetteDir: './cassettes',
redact: ['apiKey', 'authorization', /token/i],
}),
});
// CASSETTE_MODE=record → hits live API, writes cassette
// CASSETTE_MODE=replay → offline, deterministic, free
const { text } = await generateText({ model, prompt: 'Say hi' });Why middleware?
tapedeck normalizes at the SDK's own abstraction, so a cassette survives provider wire-format changes and replays streams as real streams.
| Approach | Layer | Pros | Cons |
|---|---|---|---|
| tapedeck | SDK middleware | Provider-agnostic, stream-native, zero infra | Only works with the AI SDK |
| nock / Polly | HTTP proxy | Generic, works with any HTTP | Breaks on SSE streams, leaks auth, churns on wire-format changes |
| MockLanguageModel | SDK mock | Fast, no network | Hand-write every turn; collapses on SDK bumps |
| Agent VCR | MCP boundary | Records MCP interactions | Doesn't record model calls |
| Braintrust / Langfuse | Hosted eval | Rich dashboards | Requires SaaS, not CI-native |
Modes
Calls the real model, serializes request + response to a cassette, returns the live result.
Looks up the cassette by hash, serves it. A miss throws — a changed prompt fails the test, forcing a re-record.
Passthrough. No recording, no lookup. Default for development.
Vitest helper
Pin a test to a named cassette and force replay mode for its duration. No global setup/teardown needed.
import { describe, it, expect } from 'vitest';
import { withCassette } from 'tapedeck/vitest';
describe('checkout agent', () => {
it('runs the checkout flow', async () => {
await withCassette('checkout-flow.json', async () => {
const result = await runAgent({ prompt: 'buy a t-shirt' });
expect(result.steps).toHaveLength(3);
});
});
});Streaming is first-class
In record mode tapedeck drains the live stream, captures the ordered stream parts, and re-serves them. In replay mode the recorded parts are replayed as a genuine ReadableStream via the SDK's own simulateReadableStream — streamText, UI message streams, and tool-call streaming all see the same surface they would live.
import { streamText } from 'ai';
const { textStream } = await streamText({ model, prompt: 'Tell me a story' });
for await (const delta of textStream) process.stdout.write(delta);
// Identical output whether live or replayed.Cassette format (v1)
Pretty-printed JSON, keyed by a stable hash, designed to diff cleanly in PRs.
{
"version": "tapedeck@0.1.0",
"hash": "sha256:abc123…",
"recordedAt": "2026-06-10T12:00:00Z",
"request": {
"modelProvider": "openai",
"modelId": "gpt-4o",
"prompt": [ … ],
"tools": [ … ],
"temperature": 0.7
},
"response": {
"type": "stream",
"chunks": [
{ "type": "text-delta", "id": "0", "delta": "I'll" },
{ "type": "text-delta", "id": "0", "delta": " help" },
{ "type": "tool-call", "toolCallId": "call_123", "toolName": "search", "input": "{\\"query\\":\\"t-shirts\\"}" }
]
}
}Secret redaction
Redaction is key-name based and runs at record time, so secrets never reach disk. Replaying a cassette that still contains a value a matcher would strip throws CassetteSecretError — a committed secret fails the build instead of leaking.
cassetteMiddleware({
mode: 'record',
redact: ['apiKey', 'authorization', /secret/i],
});Errors
| Error | When |
|---|---|
| CassetteMissError | replay mode, no cassette matches the hash. Message includes the hash and path searched. |
| CassetteSecretError | Replayed cassette still contains unredacted secrets. Lists offending field paths. |
| CassetteCorruptError | Invalid JSON, unknown version, or malformed response shape. |
| CassetteModeError | Invalid mode string supplied. |
All extend CassetteError, so you can catch the whole family with one instanceof.
API reference
cassetteMiddleware(options?)
Returns an AI SDK LanguageModelV3Middleware. Intercepts both doGenerate and doStream.
| Option | Type | Default | Description |
|---|---|---|---|
| mode | 'record' | 'replay' | 'live' | 'live' | Operating mode |
| cassetteDir | string | './cassettes' | Directory for cassettes |
| redact | (string | RegExp)[] | [] | Extra key matchers, merged with defaults |
| cassetteName | string | — | Force specific filename instead of hash-addressing |
withCassette(name, testFn, options?)
From tapedeck/vitest. Runs testFn with name pinned and replay forced (override via options.mode).
Adopting in a project
- Wrap your model with
cassetteMiddleware, readingmodefrom an env var. - Run your agent test once with
CASSETTE_MODE=recordagainst the live API. - Commit the generated
cassettes/*.cassette.json. - Set
CASSETTE_MODE=replayin CI. Tests are now offline, deterministic, and free.
When a prompt or tool schema changes, the hash changes, replay misses, and CI fails — re-record and commit the new cassette.