Open Source · npm install tapedeck

tapedeck

Record/replay middleware for the Vercel AI SDK. Wrap your model in one line. Run your agent test once against the live API — commit the cassette. Every CI run after that is deterministic, offline, free, and stream-accurate.

10-second demo

Install, wrap, and switch between live, record, and replay with one env var.

npm install -D tapedeck
import { 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.

ApproachLayerProsCons
tapedeckSDK middlewareProvider-agnostic, stream-native, zero infraOnly works with the AI SDK
nock / PollyHTTP proxyGeneric, works with any HTTPBreaks on SSE streams, leaks auth, churns on wire-format changes
MockLanguageModelSDK mockFast, no networkHand-write every turn; collapses on SDK bumps
Agent VCRMCP boundaryRecords MCP interactionsDoesn't record model calls
Braintrust / LangfuseHosted evalRich dashboardsRequires SaaS, not CI-native

Modes

record

Calls the real model, serializes request + response to a cassette, returns the live result.

replay

Looks up the cassette by hash, serves it. A miss throws — a changed prompt fails the test, forcing a re-record.

live

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 simulateReadableStreamstreamText, 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

ErrorWhen
CassetteMissErrorreplay mode, no cassette matches the hash. Message includes the hash and path searched.
CassetteSecretErrorReplayed cassette still contains unredacted secrets. Lists offending field paths.
CassetteCorruptErrorInvalid JSON, unknown version, or malformed response shape.
CassetteModeErrorInvalid 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.

OptionTypeDefaultDescription
mode'record' | 'replay' | 'live''live'Operating mode
cassetteDirstring'./cassettes'Directory for cassettes
redact(string | RegExp)[][]Extra key matchers, merged with defaults
cassetteNamestringForce 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

  1. Wrap your model with cassetteMiddleware, reading mode from an env var.
  2. Run your agent test once with CASSETTE_MODE=record against the live API.
  3. Commit the generated cassettes/*.cassette.json.
  4. Set CASSETTE_MODE=replay in 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.

Built by @nkwib · MIT License