API
Every public symbol exported from @nkwib/tapedeck. Source of truth: src/index.ts.
| Export | Kind | What it does |
|---|---|---|
cassetteMiddleware | function | Record/replay/compare/live middleware for wrapLanguageModel. |
TapedeckMiddleware | type | The middleware's return type: assignable to spec v3 and v4. |
withCassette | function | Vitest helper: pin a test to a named cassette. |
toFollowRoute | function | Vitest matcher: assert a tool trajectory follows a ToolRoute router. |
CassetteError | class | Base class for the whole error family. |
CassetteMissError | class | Replay miss — no cassette matched the hash. |
CassetteSecretError | class | A replayed cassette still holds unredacted secrets. |
CassetteCorruptError | class | Unreadable cassette: bad JSON, version, or shape. |
CassetteModeError | class | Invalid mode string. |
CassetteDriftError | class | compare found drift and no onCompare handler owned it. |
compareCassetteResponses | function | Compare a live response against a recorded one. |
summarizeResponse | function | Reduce a response to text, tool calls, and finish reason. |
formatCompareResult | function | Render a compare result as human-readable text. |
CassetteCompareResult | type | The structured drift report. |
ResponseSummary | type | The behavioural shape of one response. |
ToolCallSummary | type | One tool call reduced to name plus canonical input. |
computeCassetteHash | function | The stable hash used for cassette identity (async, WebCrypto). |
loadCassette | function | Read a hash-addressed cassette from a directory. |
saveCassette | function | Write a hash-addressed cassette into a directory. |
parseCassette | function | Parse + validate raw cassette text (single or multi). |
serializeCassette | function | Serialize a cassette file to its on-disk form. |
isMultiCassette | function | Narrow a CassetteFile to the multi-interaction format. |
diffCassettes | function | Semantic field-level diff of two single cassettes. |
diffCassetteFiles | function | Diff any two cassette files, pairing interactions by hash. |
mergeCassetteDirs | function | Merge cassette directories with conflict reporting. |
fileCassetteStore | function | The default filesystem CassetteStore. |
memoryCassetteStore | function | In-memory CassetteStore for tests and edge runtimes. |
withSpan | function | Run a function inside an OTel-compatible span. |
stableStringify | function | Deterministic, key-sorted JSON. |
normalizeTools | function | Strip tool descriptions before hashing. |
cassetteFilename | function | On-disk filename for a hash. |
CASSETTE_VERSION | const | Single-interaction cassette format version. |
MULTI_CASSETTE_VERSION | const | Multi-interaction cassette format version. |
REDACTED | const | Placeholder written in place of a secret. |
DEFAULT_REDACT | const | Built-in key matchers. |
cassetteMiddlewarefunction cassetteMiddleware(options?: CassetteMiddlewareOptions): TapedeckMiddleware Returns a TapedeckMiddleware for use with wrapLanguageModel, assignable under both supported ai majors with no cast.
Intercepts both doGenerate (one-shot) and doStream (streaming). Behaviour is
driven by mode — typically read from an env var, so you switch between recording
and replaying with no other code changes. A bad static mode throws CassetteModeError eagerly, even before the first call.
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'record' \| 'replay' \| 'compare' \| 'live' | 'live' | Operating mode. record calls the real model and persists request + response; replay serves a cassette by hash and throws on a miss; compare calls the model and reads the cassette, reports the drift, and never writes; live is passthrough. |
cassetteDir | string | './cassettes' | Directory cassettes are read from / written to. |
redact | (string \| RegExp)[] | [] | Extra key matchers, merged with DEFAULT_REDACT. Strings match field/header names case-insensitively; RegExps test the raw key. |
cassetteName | string | — | Force a specific filename instead of hash-addressed lookup. Named cassettes are multi-interaction: every call is stored in the file keyed by request hash. Mostly used internally by withCassette; set it for fixed fixtures. |
store | CassetteStore | filesystem | Storage backend (read/write/list). Pass memoryCassetteStore() on edge runtimes where there is no filesystem. |
tracer | TapedeckTracer | — | OTel-compatible tracer (e.g. trace.getTracer('tapedeck')). Emits tapedeck.generate / tapedeck.stream spans with mode, hash, path, and hit/miss attributes; misses record the exception with an error status. compare spans also carry tapedeck.compare_equal. |
onCompare | (result: CassetteCompareResult) => void \| Promise<void> | none | compare mode only. Called once per compared call with the report, whether or not anything diverged. Registering a handler hands you the failure policy and suppresses CassetteDriftError; with no handler the first diverging call throws. |
import { openai } from '@ai-sdk/openai';
import { generateText, wrapLanguageModel } from 'ai';
import { cassetteMiddleware } from '@nkwib/tapedeck';
const model = wrapLanguageModel({
model: openai('gpt-4o'),
middleware: cassetteMiddleware({
mode: process.env.CASSETTE_MODE ?? 'live', // record | replay | compare | live
cassetteDir: './cassettes',
redact: ['apiKey', 'authorization', /token/i],
}),
});
const { text } = await generateText({ model, prompt: 'Say hi' }); When the ambient withCassette context is active, its mode, cassetteDir, and cassetteName take precedence over the values passed here.
record mode tapedeck drains the live stream, persists the
ordered parts, then re-serves them so your code still receives the response. In replay mode the parts are replayed as a genuine ReadableStream via the SDK's own simulateReadableStream.TapedeckMiddlewareinterface TapedeckMiddleware {
readonly specificationVersion: 'v3';
wrapGenerate: <GENERATE>(options: WrapGenerateOptions<GENERATE>) => Promise<GENERATE>;
wrapStream: <STREAM>(options: WrapStreamOptions<STREAM>) => Promise<STREAM>;
} What cassetteMiddleware returns. ai@6 takes a
language-model spec v3 middleware and ai@7 takes spec v4; the two concrete
types are mutually unassignable, so a middleware annotated with either one
forces consumers of the other major to write as unknown as LanguageModelMiddleware. TapedeckMiddleware is typed
structurally instead: it declares only the fields tapedeck reads and stays
generic in the result type, so whatever doGenerate hands in is what wrapGenerate hands back, and one object is assignable to both spec surfaces
with no cast on either side.
specificationVersion stays 'v3' because v4 hosts accept any string while v3
hosts accept only 'v3'. The companion types SpecModel, SpecCallOptions, WrapGenerateOptions<GENERATE>, and WrapStreamOptions<STREAM> are exported
too: SpecCallOptions keeps prompt and tools as unknown on purpose, since
tapedeck hashes and serializes them verbatim rather than interpreting them. test/types.test-d.ts asserts assignability to both spec surfaces on every CI
run. See Compatibility.
withCassettefunction withCassette<T>(
cassetteName: string,
testFn: () => T | Promise<T>,
options?: WithCassetteOptions
): Promise<T> From @nkwib/tapedeck/vitest. Runs testFn with cassetteName pinned and replay forced for its duration, publishing an ambient context (via AsyncLocalStorage)
that any active cassetteMiddleware instance picks up. The
context tears down automatically on exit — no global setup/teardown needed.
options.mode overrides the forced replay; options.cassetteDir overrides the
directory. The WithCassetteOptions shape is { cassetteDir?: string; mode?: CassetteMode }.
The named cassette is multi-interaction: every model call inside testFn is
stored in the one file keyed by request hash, and each call replays its own
response in any order. Each withCassette run is one recording session — in record mode the first write starts the file fresh, so re-recording never
leaves stale interactions behind.
import { describe, it, expect } from 'vitest';
import { withCassette } from '@nkwib/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);
});
});
}); tapedeck/vitest also re-exports cassetteMiddleware and the CassetteMiddlewareOptions / CassetteMode types, so a test file can import everything it needs
from the one entry point.CassetteErrorclass CassetteError extends Error {} The base class every tapedeck error extends. Catch it to handle the whole family
with a single instanceof. The constructor sets name from the concrete
subclass and restores the prototype chain (Object.setPrototypeOf), so instanceof works across module boundaries and downlevel transpilation.
import { CassetteError } from '@nkwib/tapedeck';
try {
await generateText({ model, prompt });
} catch (err) {
if (err instanceof CassetteError) {
// a miss, a leaked secret, a corrupt file, or a bad mode
}
throw err;
} CassetteMissErrorclass CassetteMissError extends CassetteError {
readonly hash: string;
readonly cassetteDir: string;
readonly cassettePath: string;
} Thrown in replay mode when no cassette matches the request hash. The message
embeds the computed hash, the path searched, and a hint to re-run with CASSETTE_MODE=record. This is the load-bearing failure: a changed prompt or
tool schema produces a different hash, misses, and fails CI loudly instead of
replaying stale data.
CassetteSecretErrorclass CassetteSecretError extends CassetteError {
readonly paths: string[];
readonly cassettePath: string | undefined;
} Thrown when a cassette being replayed still contains a value that the active redact matchers would have stripped — i.e. a secret leaked into a committed
cassette. paths lists the offending dotted field paths so the leak is easy to
locate before it ships.
CassetteCorruptErrorclass CassetteCorruptError extends CassetteError {
readonly cassettePath: string;
readonly reason: string;
} Thrown when a cassette file exists but is unreadable: invalid JSON, an unknown or
missing version, a malformed response shape, or a response type that doesn't
match the call (a stream cassette served to doGenerate, or vice versa).
CassetteModeErrorclass CassetteModeError extends CassetteError {
readonly mode: string;
} Thrown when an invalid mode string is supplied — anything other than record, replay, compare, or live. Raised eagerly at middleware construction when mode is statically set, and otherwise when the ambient context resolves.
CassetteDriftErrorclass CassetteDriftError extends CassetteError {
readonly result: CassetteCompareResult;
readonly cassettePath: string;
} Thrown in compare mode when the live response diverged from the cassette and
no onCompare handler took ownership of the policy. The message carries the
rendered report (see formatCompareResult) plus a hint
to re-record if the new behaviour is intended; result carries the same report
structurally. This is the default failure path, so drift can never pass
silently, and it is what makes npx tapedeck compare pnpm test exit nonzero.
Register onCompare and nothing is thrown: you get every report, drift or not,
and decide when the suite fails.
const drifted: CassetteCompareResult[] = [];
cassetteMiddleware({
mode: 'compare',
onCompare(result) {
if (!result.equal) drifted.push(result);
},
});
afterAll(() => {
if (drifted.length > 0) throw new Error(drifted.map(formatCompareResult).join('\n\n'));
}); compareCassetteResponsesfunction compareCassetteResponses(
recorded: CassetteResponse,
live: CassetteResponse,
context: CompareContext
): CassetteCompareResult The drift comparison itself, usable outside the middleware. Pure: it reads both
responses and returns a report, and never touches a cassette. context is the
identity carried into the report
({ hash, cassettePath, modelProvider, modelId }).
Three signals decide the verdict. The tool-call trajectory must have the
same tool names in the same order with the same inputs, where inputs are
canonicalized as sorted JSON so key order is never drift. The finish reason compares the unified label (stop, tool-calls, …), not the provider-raw one. Text is classified exact, normalized (equal after trimming, collapsing
whitespace, and lowercasing), or different. result.equal is true when the
kind is unchanged, the trajectory and finish reason match, and the text is no
worse than normalized: prose is not a contract, the trajectory is. There is no
similarity score. See compare never writes.
tapedeck compare <script> [args...] runs a script (or a
command on PATH, e.g. tapedeck compare pnpm test) with CASSETTE_MODE=compare and propagates the child's exit code, so an
unhandled CassetteDriftError fails the job. It sits alongside tapedeck record and tapedeck replay, and like them it
is the only thing the command does: no cassette is written, read, or moved by
the CLI itself.summarizeResponsefunction summarizeResponse(response: CassetteResponse): ResponseSummary Reduce a recorded or live response to the parts compare looks at: all assistant
text concatenated in emission order, the tool calls in emission order, and the
unified finish reason. Works on both response kinds, flattening a generate content array or a stream chunk array into the same shape, which is what lets
compare notice that a call that used to stream now returns one-shot.
formatCompareResultfunction formatCompareResult(result: CassetteCompareResult): string Render a report as the human-readable text CassetteDriftError embeds. Only the diverging signals
are printed, trajectories render as search(…) -> checkout(…), and text sides
are JSON-quoted so trailing whitespace and newlines are visible.
tapedeck: live response drifted from the cassette.
sha256:9f2c… (stream, openai/gpt-4o)
cassette: ./cassettes/checkout-flow.json
tool calls: call 2: tool 'checkout' != 'refund'
cassette: search({"q":"shirt"}) -> checkout({"id":1})
live: search({"q":"shirt"}) -> refund({"id":1})
finish reason: stop -> tool-calls CassetteCompareResultinterface CassetteCompareResult extends CompareContext {
equal: boolean;
kind: 'generate' | 'stream';
kindChanged: boolean;
text: { status: 'exact' | 'normalized' | 'different'; recorded: string; live: string };
toolCalls: {
equal: boolean;
recorded: ToolCallSummary[];
live: ToolCallSummary[];
divergedAt: number | null;
reason: string | null;
};
finishReason: { equal: boolean; recorded: string | null; live: string | null };
} The structured report handed to onCompare and carried by CassetteDriftError. divergedAt is the index of the
first divergent tool call and reason explains it in one line
(call 2: tool 'checkout' != 'refund'), so a reviewer can agree that it is
drift without trusting a number. text.status is always reported even when equal is true, which is the hook for a stricter policy. The individual pieces
are exported as CompareTextResult, CompareToolCalls, CompareFinishReason, TextDivergence, and CompareContext.
ResponseSummaryinterface ResponseSummary {
kind: 'generate' | 'stream';
text: string;
toolCalls: ToolCallSummary[];
finishReason: string | null;
} The behavioural shape of a response: everything compare looks at, nothing else.
Returned by summarizeResponse. finishReason is the
unified label, or null when the response carries none.
ToolCallSummaryinterface ToolCallSummary {
toolName: string;
input: string;
} One tool call, reduced to the parts that define an agent's trajectory. input is the tool input as canonical JSON with keys sorted, so a reordered key never
reads as drift; when the input is not valid JSON the raw string is kept.
computeCassetteHashfunction computeCassetteHash(request: CassetteRequestKey): Promise<string> The stable hash that gives a cassette its identity. Resolves to the bare hex
SHA-256 digest of the canonicalized, key-sorted JSON of { modelProvider, modelId, prompt, toolSchemas, maxOutputTokens, temperature, topP }.
Tool schemas are normalized first (see normalizeTools), so
cosmetic description changes don't invalidate a cassette but a changed prompt,
input schema, or sampling param does. Async since 0.2.0 — hashing uses WebCrypto
(crypto.subtle), available in Node ≥18, Workers, and browsers; digests are
identical to the previous node:crypto implementation.
import { computeCassetteHash } from '@nkwib/tapedeck';
const hash = await computeCassetteHash({
modelProvider: 'openai',
modelId: 'gpt-4o',
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hi' }] }],
temperature: 0.7,
});
// '9f2c…' (bare hex; callers prefix 'sha256:' for display) loadCassettefunction loadCassette(hash: string, dir: string): Promise<CassetteFile | null> Read a hash-addressed cassette from dir. Resolves to null on a miss (the file
doesn't exist) and throws CassetteCorruptError for a
file that exists but is unreadable or malformed. CassetteFile is the Cassette | MultiCassette union — narrow with isMultiCassette.
saveCassettefunction saveCassette(hash: string, dir: string, cassette: Cassette): Promise<void> Write cassette into dir under its hash-addressed filename, creating parent
directories as needed. The file is pretty-printed JSON so it diffs cleanly in PRs.
parseCassettefunction parseCassette(raw: string, path: string): CassetteFile Parse and validate raw cassette text — single (v1) or multi-interaction (v2).
Throws CassetteCorruptError for bad JSON, an unknown
version, or a malformed response shape. path is only used in error messages.
serializeCassettefunction serializeCassette(cassette: CassetteFile): string Serialize a cassette file to its on-disk form: pretty-printed JSON with a trailing newline, so cassettes diff cleanly in PRs.
isMultiCassettefunction isMultiCassette(file: CassetteFile): file is MultiCassette Narrow a CassetteFile to the multi-interaction format. Multi cassettes hold interactions: { hash, request, response }[] — one entry per model call a
named-cassette test makes.
diffCassettesfunction diffCassettes(a: Cassette, b: Cassette): CassetteDiffResult Structurally diff two single cassettes, ignoring recordedAt. The result lists
leaf-level divergences as dotted paths (request.prompt[0].content[0].text)
plus a hashChanged flag. formatCassetteDiff(result) renders it as
human-readable text — this is what npx tapedeck diff prints.
diffCassetteFilesfunction diffCassetteFiles(a: CassetteFile, b: CassetteFile): CassetteFileDiffResult Diff two cassette files of any format, pairing interactions by request hash.
The result reports hashes present only on one side (onlyA / onlyB) and
field-level divergences for shared hashes (changed). Render with formatCassetteFileDiff(result).
mergeCassetteDirsfunction mergeCassetteDirs(
srcDir: string,
destDir: string,
options?: { force?: boolean; store?: CassetteStore }
): Promise<MergeCassettesResult> Merge every cassette in srcDir into destDir. New files are copied,
identical files skipped, and same-name files with different content are
reported as conflicts — left untouched unless force is set. Source files are
validated before propagating, so a corrupt fixture fails the merge instead of
spreading. Backs npx tapedeck merge.
fileCassetteStorefunction fileCassetteStore(): CassetteStore The default storage backend: cassettes as files on disk. node:fs is imported
lazily inside each method, so importing tapedeck has no Node-only side effects
— the core stays edge-importable.
memoryCassetteStorefunction memoryCassetteStore(
seed?: Record<string, string> | Map<string, string>
): CassetteStore & { entries: Map<string, string> } An in-memory CassetteStore for tests and edge runtimes — seed it with
cassette text at build time (e.g. bundled into a Worker) or back a custom store
with KV/R2 using the same three-method interface
(read / write / list).
import { cassetteMiddleware, memoryCassetteStore } from '@nkwib/tapedeck';
const store = memoryCassetteStore({
'cassettes/abc….cassette.json': cassetteJsonText,
});
cassetteMiddleware({ mode: 'replay', store }); withSpanfunction withSpan<T>(
tracer: TapedeckTracer | undefined,
name: string,
attributes: Record<string, string | number | boolean>,
fn: (span?: TapedeckSpan) => Promise<T>
): Promise<T> Run fn inside a span when a tracer is configured: sets attributes up front,
marks OK/ERROR status (SPAN_STATUS_OK / SPAN_STATUS_ERROR), records
exceptions, and always ends the span. With no tracer it's a plain call. The TapedeckTracer / TapedeckSpan types are structural subsets of the
OpenTelemetry interfaces, so trace.getTracer('tapedeck') just works — and
tapedeck keeps zero runtime dependencies.
toFollowRoutefunction toFollowRoute(received: unknown, router: RouteLike): ToFollowRouteResult From @nkwib/tapedeck/vitest. A vitest matcher asserting that a tool-call
trajectory only makes transitions a ToolRoute router allows. Accepts AI SDK result.steps, a flat { toolName }[] list, or bare tool-name strings, and
pinpoints the first illegal transition. The router is typed structurally
({ adjacency, routerVersion }), so any ToolRoute version works — and
toolroute doesn't need to be installed at all.
import { expect } from 'vitest';
import { toFollowRoute } from '@nkwib/tapedeck/vitest';
expect.extend({ toFollowRoute });
expect(result.steps).toFollowRoute(router); stableStringifyfunction stableStringify(value: unknown): string Deterministic JSON.stringify: object keys are emitted in sorted order at every
level, so semantically equal requests serialize identically. The canonicalization
primitive underneath computeCassetteHash.
normalizeToolsfunction normalizeTools(tools: CassetteRequestKey['tools']): unknown Strip description fields recursively from a tool array before hashing.
Descriptions are irrelevant to behaviour and churn frequently, so dropping them
keeps a cassette stable across doc-only edits while still keying on the input
schema. Returns undefined when tools is absent.
cassetteFilenamefunction cassetteFilename(hash: string): string The on-disk filename for a hash-addressed cassette: `${hash}.cassette.json`.
CASSETTE_VERSIONconst CASSETTE_VERSION = 'tapedeck@0.1.0'; The single-interaction (v1) cassette format version, stamped into every
hash-addressed file's version field. On read, a cassette whose version
doesn't start with tapedeck@ is rejected with CassetteCorruptError.
MULTI_CASSETTE_VERSIONconst MULTI_CASSETTE_VERSION = 'tapedeck@0.3.0'; The multi-interaction (v2) cassette format version, stamped into named cassette
files. A v2 file holds interactions: { hash, request, response }[] instead
of a single request/response pair.
REDACTEDconst REDACTED = '[REDACTED]'; The placeholder string written in place of a matched secret value at record time.
DEFAULT_REDACTconst DEFAULT_REDACT: (string | RegExp)[] = [
'apiKey',
'authorization',
'x-api-key',
'bearer',
'token',
]; The built-in key matchers, always applied even when the caller passes none. Any redact option you supply is merged on top of these — you extend the defaults,
never replace them.