cassette-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Cassette — optional one-call TS/JS shim.
3
+ *
4
+ * Not strictly required: setting `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL` to the gateway URL works
5
+ * on its own. `cassetteBaseURL()` composes the right URL; `use()` sets the standard env vars.
6
+ *
7
+ * import { use } from "cassette-sdk";
8
+ * use(); // reads CASSETTE_GATEWAY / CASSETTE_PROJECT / CASSETTE_MODE
9
+ *
10
+ * Or pass it explicitly when constructing a client:
11
+ *
12
+ * import OpenAI from "openai";
13
+ * import { cassetteBaseURL } from "cassette-sdk";
14
+ * const client = new OpenAI({ baseURL: cassetteBaseURL("openai") });
15
+ */
16
+ export type Provider = "openai" | "anthropic" | "google";
17
+ export type Mode = "record" | "replay" | "auto";
18
+ interface Opts {
19
+ gateway?: string;
20
+ project?: string;
21
+ mode?: Mode;
22
+ }
23
+ export declare function cassetteBaseURL(provider: Provider, opts?: Opts): string;
24
+ /** Set the standard SDK base-URL env vars to point at the gateway. Returns what it set. */
25
+ export declare function use(opts?: Opts): Record<string, string>;
26
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ const PROVIDER_SUFFIX = {
2
+ openai: "/openai/v1",
3
+ anthropic: "/anthropic",
4
+ google: "/google",
5
+ };
6
+ function resolve(opts = {}) {
7
+ const env = globalThis.process?.env ?? {};
8
+ return {
9
+ gateway: (opts.gateway ?? env.CASSETTE_GATEWAY ?? "http://localhost:8787").replace(/\/+$/, ""),
10
+ project: opts.project ?? env.CASSETTE_PROJECT ?? "default",
11
+ mode: (opts.mode ?? env.CASSETTE_MODE ?? "auto"),
12
+ };
13
+ }
14
+ export function cassetteBaseURL(provider, opts = {}) {
15
+ const { gateway, project, mode } = resolve(opts);
16
+ return `${gateway}/${project}/${mode}${PROVIDER_SUFFIX[provider]}`;
17
+ }
18
+ /** Set the standard SDK base-URL env vars to point at the gateway. Returns what it set. */
19
+ export function use(opts = {}) {
20
+ const env = globalThis.process?.env;
21
+ const map = {
22
+ OPENAI_BASE_URL: cassetteBaseURL("openai", opts),
23
+ ANTHROPIC_BASE_URL: cassetteBaseURL("anthropic", opts),
24
+ GOOGLE_GEMINI_BASE_URL: cassetteBaseURL("google", opts),
25
+ };
26
+ if (env)
27
+ Object.assign(env, map);
28
+ return map;
29
+ }
@@ -0,0 +1,10 @@
1
+ type Mode = "record" | "replay" | "auto";
2
+ export declare class CassetteMiss extends Error {
3
+ }
4
+ export interface RecorderOpts {
5
+ project?: string;
6
+ mode?: Mode;
7
+ cassetteDir?: string;
8
+ }
9
+ export declare function recordingFetch(opts?: RecorderOpts): typeof fetch;
10
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * In-process recorder for Node (the surface we own — no gateway required).
3
+ *
4
+ * Returns a `fetch`-compatible function that records/replays at the HTTP layer. Pass it to the SDK:
5
+ *
6
+ * import OpenAI from "openai";
7
+ * import { recordingFetch } from "cassette-sdk/recorder";
8
+ * const client = new OpenAI({ fetch: recordingFetch({ project: "demo" }) });
9
+ *
10
+ * In vitest/jest, wrap the global fetch in setup:
11
+ * globalThis.fetch = recordingFetch({ project: "demo" });
12
+ *
13
+ * Cassettes conform to SPEC.md and interoperate with the Python recorder and the gateway.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ const VOLATILE = new Set(["stream_options", "user", "metadata"]);
19
+ const DROP_RESP = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
20
+ function canonical(value) {
21
+ if (value === null || typeof value !== "object")
22
+ return JSON.stringify(value);
23
+ if (Array.isArray(value))
24
+ return "[" + value.map(canonical).join(",") + "]";
25
+ const obj = value;
26
+ return ("{" +
27
+ Object.keys(obj)
28
+ .filter((k) => !VOLATILE.has(k))
29
+ .sort()
30
+ .map((k) => JSON.stringify(k) + ":" + canonical(obj[k]))
31
+ .join(",") +
32
+ "}");
33
+ }
34
+ function fingerprint(method, url, body) {
35
+ let norm = body;
36
+ if (body) {
37
+ try {
38
+ norm = canonical(JSON.parse(body));
39
+ }
40
+ catch {
41
+ /* non-JSON body — hash verbatim */
42
+ }
43
+ }
44
+ return createHash("sha256").update([method.toUpperCase(), url, norm].join("\n")).digest("hex");
45
+ }
46
+ export class CassetteMiss extends Error {
47
+ }
48
+ export function recordingFetch(opts = {}) {
49
+ const env = globalThis.process?.env ?? {};
50
+ const project = opts.project ?? env.CASSETTE_PROJECT ?? "default";
51
+ const mode = opts.mode ?? env.CASSETTE_MODE ?? "auto";
52
+ const dir = join(opts.cassetteDir ?? env.CASSETTE_DIR ?? ".cassettes", project);
53
+ mkdirSync(dir, { recursive: true });
54
+ const realFetch = globalThis.fetch.bind(globalThis);
55
+ return async function cassetteFetch(input, init) {
56
+ const req = new Request(input, init);
57
+ const body = req.method === "GET" || req.method === "HEAD" ? "" : await req.clone().text();
58
+ const fp = fingerprint(req.method, req.url, body);
59
+ const path = join(dir, `${fp}.json`);
60
+ if ((mode === "replay" || mode === "auto") && existsSync(path)) {
61
+ const rec = JSON.parse(readFileSync(path, "utf8"));
62
+ const headers = new Headers();
63
+ for (const [k, v] of Object.entries(rec.response.headers)) {
64
+ if (!DROP_RESP.has(k.toLowerCase()))
65
+ headers.set(k, v);
66
+ }
67
+ headers.set("x-cassette", "replay");
68
+ return new Response(rec.response.body, { status: rec.response.status, headers });
69
+ }
70
+ if (mode === "replay") {
71
+ throw new CassetteMiss(`no cassette for ${req.method} ${req.url} (fp=${fp.slice(0, 12)})`);
72
+ }
73
+ const resp = await realFetch(req);
74
+ const text = await resp.text();
75
+ if (resp.ok) {
76
+ const headers = {};
77
+ resp.headers.forEach((v, k) => {
78
+ if (!/^(authorization|set-cookie)$/i.test(k))
79
+ headers[k] = v;
80
+ });
81
+ writeFileSync(path, JSON.stringify({
82
+ v: 1,
83
+ fingerprint: fp,
84
+ request: { method: req.method, url: req.url, body },
85
+ response: { status: resp.status, headers, body: text },
86
+ }, null, 2));
87
+ }
88
+ return new Response(text, { status: resp.status, headers: resp.headers });
89
+ };
90
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "cassette-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Record/replay LLM & agent API calls for fast, free, deterministic tests. In-process recorder + optional gateway shim.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
10
+ "./recorder": { "types": "./dist/recorder.d.ts", "import": "./dist/recorder.js" }
11
+ },
12
+ "files": ["dist"],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": ["llm", "agent", "testing", "record", "replay", "vcr", "openai", "anthropic", "ci", "vitest", "jest"],
18
+ "license": "MIT",
19
+ "repository": { "type": "git", "url": "https://github.com/NOVUS-STUDIOS-DEV/cassette" },
20
+ "homepage": "https://github.com/NOVUS-STUDIOS-DEV/cassette",
21
+ "engines": { "node": ">=18" },
22
+ "devDependencies": {
23
+ "@types/node": "^20.0.0",
24
+ "typescript": "^5.6.0"
25
+ }
26
+ }