@node-llm/testing 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +541 -0
  3. package/dist/Mocker.d.ts +58 -0
  4. package/dist/Mocker.d.ts.map +1 -0
  5. package/dist/Mocker.js +247 -0
  6. package/dist/Scrubber.d.ts +18 -0
  7. package/dist/Scrubber.d.ts.map +1 -0
  8. package/dist/Scrubber.js +68 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +2 -0
  12. package/dist/vcr.d.ts +57 -0
  13. package/dist/vcr.d.ts.map +1 -0
  14. package/dist/vcr.js +291 -0
  15. package/package.json +19 -0
  16. package/src/Mocker.ts +311 -0
  17. package/src/Scrubber.ts +85 -0
  18. package/src/index.ts +2 -0
  19. package/src/vcr.ts +377 -0
  20. package/test/cassettes/custom-scrub-config.json +33 -0
  21. package/test/cassettes/defaults-plus-custom.json +33 -0
  22. package/test/cassettes/explicit-sugar-test.json +33 -0
  23. package/test/cassettes/feature-1-vcr.json +33 -0
  24. package/test/cassettes/global-config-keys.json +33 -0
  25. package/test/cassettes/global-config-merge.json +33 -0
  26. package/test/cassettes/global-config-patterns.json +33 -0
  27. package/test/cassettes/global-config-reset.json +33 -0
  28. package/test/cassettes/global-config-test.json +33 -0
  29. package/test/cassettes/streaming-chunks.json +18 -0
  30. package/test/cassettes/testunitdxtestts-vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +33 -0
  31. package/test/cassettes/vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +28 -0
  32. package/test/cassettes/vcr-streaming.json +17 -0
  33. package/test/helpers/MockProvider.ts +75 -0
  34. package/test/unit/ci.test.ts +36 -0
  35. package/test/unit/dx.test.ts +86 -0
  36. package/test/unit/mocker-debug.test.ts +68 -0
  37. package/test/unit/mocker.test.ts +46 -0
  38. package/test/unit/multimodal.test.ts +46 -0
  39. package/test/unit/scoping.test.ts +54 -0
  40. package/test/unit/scrubbing.test.ts +110 -0
  41. package/test/unit/streaming.test.ts +51 -0
  42. package/test/unit/strict-mode.test.ts +112 -0
  43. package/test/unit/tools.test.ts +58 -0
  44. package/test/unit/vcr-global-config.test.ts +87 -0
  45. package/test/unit/vcr-mismatch.test.ts +172 -0
  46. package/test/unit/vcr-passthrough.test.ts +68 -0
  47. package/test/unit/vcr-streaming.test.ts +86 -0
  48. package/test/unit/vcr.test.ts +34 -0
  49. package/tsconfig.json +9 -0
  50. package/vitest.config.ts +12 -0
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./vcr.js";
2
+ export * from "./Mocker.js";
package/src/vcr.ts ADDED
@@ -0,0 +1,377 @@
1
+ import { Provider, providerRegistry } from "@node-llm/core";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { Scrubber } from "./Scrubber.js";
5
+
6
+ // Internal state for nested scoping (Feature 12)
7
+ const currentVCRScopes: string[] = [];
8
+
9
+ // Try to import Vitest's expect to get test state
10
+ let vitestExpect: any;
11
+ try {
12
+ // @ts-ignore
13
+ import("vitest").then((m) => {
14
+ vitestExpect = m.expect;
15
+ });
16
+ } catch {
17
+ // Not in vitest env
18
+ }
19
+
20
+ export type VCRMode = "record" | "replay" | "auto" | "passthrough";
21
+
22
+ export interface VCRInteraction {
23
+ method: string;
24
+ request: any;
25
+ response: any;
26
+ chunks?: any[];
27
+ }
28
+
29
+ export interface VCRCassette {
30
+ name: string;
31
+ version?: "1.0";
32
+ metadata?: {
33
+ recordedAt?: string;
34
+ recordedFrom?: string;
35
+ provider?: string;
36
+ duration?: number;
37
+ };
38
+ interactions: VCRInteraction[];
39
+ }
40
+
41
+ export interface VCROptions {
42
+ mode?: VCRMode;
43
+ scrub?: (data: any) => any;
44
+ cassettesDir?: string;
45
+ scope?: string | string[]; // Allow single or multiple scopes
46
+ sensitivePatterns?: RegExp[];
47
+ sensitiveKeys?: string[];
48
+ }
49
+
50
+ export class VCR {
51
+ private cassette: VCRCassette;
52
+ private interactionIndex = 0;
53
+ private mode: VCRMode;
54
+ private filePath: string;
55
+ private scrubber: Scrubber;
56
+ private recordStartTime: number = 0;
57
+
58
+ constructor(name: string, options: VCROptions = {}) {
59
+ // 1. Merge Global Defaults
60
+ // Explicitly merge arrays to avoid overwriting global sensitive keys/patterns
61
+ const mergedOptions: VCROptions = {
62
+ ...globalVCROptions,
63
+ ...options,
64
+ sensitivePatterns: [
65
+ ...(globalVCROptions.sensitivePatterns || []),
66
+ ...(options.sensitivePatterns || [])
67
+ ],
68
+ sensitiveKeys: [...(globalVCROptions.sensitiveKeys || []), ...(options.sensitiveKeys || [])]
69
+ };
70
+
71
+ // 2. Resolve Base Directory (Env -> Option -> Default)
72
+ // Rails-inspired organization: cassettes belong inside the test folder
73
+ const baseDir = mergedOptions.cassettesDir || process.env.VCR_CASSETTE_DIR || "test/cassettes";
74
+
75
+ // 2. Resolve Hierarchical Scopes
76
+ const scopes: string[] = [];
77
+ if (Array.isArray(mergedOptions.scope)) {
78
+ scopes.push(...mergedOptions.scope);
79
+ } else if (mergedOptions.scope) {
80
+ scopes.push(mergedOptions.scope);
81
+ } else {
82
+ scopes.push(...currentVCRScopes);
83
+ }
84
+
85
+ // 3. Construct Final Directory Path
86
+ const targetDir = path.join(baseDir, ...scopes.map((s) => this.slugify(s)));
87
+
88
+ // Robust path resolution: Never join CWD if the target is already absolute
89
+ if (path.isAbsolute(targetDir)) {
90
+ this.filePath = path.join(targetDir, `${this.slugify(name)}.json`);
91
+ } else {
92
+ this.filePath = path.join(process.cwd(), targetDir, `${this.slugify(name)}.json`);
93
+ }
94
+
95
+ const initialMode = mergedOptions.mode || (process.env.VCR_MODE as VCRMode) || "auto";
96
+ const isCI = !!process.env.CI;
97
+ const exists = fs.existsSync(this.filePath);
98
+
99
+ // CI Enforcement
100
+ if (isCI) {
101
+ if (initialMode === "record") {
102
+ throw new Error(`VCR[${name}]: Recording cassettes is not allowed in CI.`);
103
+ }
104
+ if (initialMode === "auto" && !exists) {
105
+ throw new Error(
106
+ `VCR[${name}]: Cassette missing in CI. Run locally to generate ${this.filePath}`
107
+ );
108
+ }
109
+ }
110
+
111
+ // Mode Resolution:
112
+ // - "record": Always record (overwrites existing cassette)
113
+ // - "replay": Always replay (fails if cassette missing)
114
+ // - "auto": Replay if exists, otherwise FAIL (requires explicit record)
115
+ // - "passthrough": No VCR, make real calls
116
+ if (initialMode === "auto") {
117
+ if (exists) {
118
+ this.mode = "replay";
119
+ } else {
120
+ throw new Error(
121
+ `VCR[${name}]: Cassette not found at ${this.filePath}. ` +
122
+ `Use mode: "record" to create it, then switch back to "auto" or "replay".`
123
+ );
124
+ }
125
+ } else {
126
+ this.mode = initialMode;
127
+ }
128
+
129
+ this.scrubber = new Scrubber({
130
+ customScrubber: mergedOptions.scrub,
131
+ sensitivePatterns: mergedOptions.sensitivePatterns,
132
+ sensitiveKeys: mergedOptions.sensitiveKeys
133
+ });
134
+
135
+ if (this.mode === "replay") {
136
+ if (!exists) {
137
+ throw new Error(`VCR[${name}]: Cassette not found at ${this.filePath}`);
138
+ }
139
+ this.cassette = JSON.parse(fs.readFileSync(this.filePath, "utf-8"));
140
+ } else {
141
+ this.cassette = {
142
+ name,
143
+ version: "1.0",
144
+ metadata: {
145
+ recordedAt: new Date().toISOString()
146
+ },
147
+ interactions: []
148
+ };
149
+ this.recordStartTime = Date.now();
150
+ }
151
+ }
152
+
153
+ get currentMode() {
154
+ return this.mode;
155
+ }
156
+
157
+ async stop() {
158
+ if (this.mode === "record" && this.interactionsCount > 0) {
159
+ const dir = path.dirname(this.filePath);
160
+ if (!fs.existsSync(dir)) {
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ }
163
+
164
+ // Update metadata with duration
165
+ const duration = Date.now() - this.recordStartTime;
166
+ if (this.cassette.metadata) {
167
+ this.cassette.metadata.duration = duration;
168
+ }
169
+
170
+ fs.writeFileSync(this.filePath, JSON.stringify(this.cassette, null, 2));
171
+ }
172
+ providerRegistry.setInterceptor(undefined);
173
+ }
174
+
175
+ private get interactionsCount() {
176
+ return this.cassette.interactions.length;
177
+ }
178
+
179
+ public async execute(
180
+ method: string,
181
+ originalMethod: (...args: any[]) => Promise<any>,
182
+ request: any
183
+ ): Promise<any> {
184
+ if (this.mode === "replay") {
185
+ const interaction = this.cassette.interactions[this.interactionIndex++];
186
+ if (!interaction) {
187
+ throw new Error(`VCR[${this.cassette.name}]: No more interactions for ${method}`);
188
+ }
189
+ return interaction.response;
190
+ }
191
+
192
+ const response = await originalMethod(request);
193
+
194
+ if (this.mode === "record") {
195
+ const interaction = this.scrubber.scrub({
196
+ method,
197
+ request: this.clone(request),
198
+ response: this.clone(response)
199
+ }) as VCRInteraction;
200
+
201
+ this.cassette.interactions.push(interaction);
202
+ }
203
+
204
+ return response;
205
+ }
206
+
207
+ public async *executeStream(
208
+ method: string,
209
+ originalMethod: (...args: any[]) => AsyncIterable<any>,
210
+ request: any
211
+ ): AsyncIterable<any> {
212
+ if (this.mode === "replay") {
213
+ const interaction = this.cassette.interactions[this.interactionIndex++];
214
+ if (!interaction || !interaction.chunks) {
215
+ throw new Error(`VCR[${this.cassette.name}]: No streaming interactions found`);
216
+ }
217
+ for (const chunk of interaction.chunks) {
218
+ yield chunk;
219
+ }
220
+ return;
221
+ }
222
+
223
+ const stream = originalMethod(request);
224
+ const chunks: any[] = [];
225
+
226
+ for await (const chunk of stream) {
227
+ if (this.mode === "record") chunks.push(this.clone(chunk));
228
+ yield chunk;
229
+ }
230
+
231
+ if (this.mode === "record") {
232
+ const interaction = this.scrubber.scrub({
233
+ method,
234
+ request: this.clone(request),
235
+ response: null,
236
+ chunks: chunks.map((c) => this.clone(c))
237
+ }) as VCRInteraction;
238
+
239
+ this.cassette.interactions.push(interaction);
240
+ }
241
+ }
242
+
243
+ private clone(obj: any) {
244
+ try {
245
+ return JSON.parse(JSON.stringify(obj));
246
+ } catch {
247
+ return obj;
248
+ }
249
+ }
250
+
251
+ private slugify(text: string): string {
252
+ return text
253
+ .toString()
254
+ .toLowerCase()
255
+ .trim()
256
+ .replace(/\s+/g, "-")
257
+ .replace(/[^\w-]+/g, "")
258
+ .replace(/--+/g, "-");
259
+ }
260
+ }
261
+
262
+ const EXECUTION_METHODS = ["chat", "stream", "paint", "transcribe", "moderate", "embed"];
263
+
264
+ export function setupVCR(name: string, options: VCROptions = {}) {
265
+ const vcr = new VCR(name, options);
266
+
267
+ providerRegistry.setInterceptor((provider: Provider) => {
268
+ return new Proxy(provider, {
269
+ get(target, prop, receiver) {
270
+ const originalValue = Reflect.get(target, prop, receiver);
271
+ const method = prop.toString();
272
+
273
+ if (typeof originalValue === "function" && EXECUTION_METHODS.includes(method)) {
274
+ return function (...args: any[]) {
275
+ if (method === "stream") {
276
+ return vcr.executeStream(method, originalValue.bind(target), args[0]);
277
+ }
278
+ return vcr.execute(method, originalValue.bind(target), args[0]);
279
+ };
280
+ }
281
+ return originalValue;
282
+ }
283
+ });
284
+ });
285
+
286
+ return vcr;
287
+ }
288
+
289
+ /**
290
+ * One-line DX Sugar for VCR testing.
291
+ */
292
+ export function withVCR(fn: () => Promise<void>): () => Promise<void>;
293
+ export function withVCR(name: string, fn: () => Promise<void>): () => Promise<void>;
294
+ export function withVCR(options: VCROptions, fn: () => Promise<void>): () => Promise<void>;
295
+ export function withVCR(
296
+ name: string,
297
+ options: VCROptions,
298
+ fn: () => Promise<void>
299
+ ): () => Promise<void>;
300
+ export function withVCR(...args: any[]): () => Promise<void> {
301
+ // Capture scopes at initialization time
302
+ const capturedScopes = [...currentVCRScopes];
303
+
304
+ return async function () {
305
+ let name: string | undefined;
306
+ let options: VCROptions = {};
307
+ let fn: () => Promise<void>;
308
+
309
+ if (typeof args[0] === "function") {
310
+ fn = args[0];
311
+ } else if (typeof args[0] === "string") {
312
+ name = args[0];
313
+ if (typeof args[1] === "function") {
314
+ fn = args[1];
315
+ } else {
316
+ options = args[1] || {};
317
+ fn = args[2];
318
+ }
319
+ } else {
320
+ options = args[0] || {};
321
+ fn = args[1];
322
+ }
323
+
324
+ // Pass captured inherited scopes if not explicitly overridden
325
+ if (capturedScopes.length > 0 && !options.scope) {
326
+ options.scope = capturedScopes;
327
+ }
328
+
329
+ if (!name && vitestExpect) {
330
+ const state = vitestExpect.getState();
331
+ name = state.currentTestName || "unnamed-test";
332
+ }
333
+
334
+ if (!name) throw new Error("VCR: Could not determine cassette name.");
335
+
336
+ const vcr = setupVCR(name, options);
337
+ try {
338
+ await fn();
339
+ } finally {
340
+ await vcr.stop();
341
+ }
342
+ };
343
+ }
344
+
345
+ // Global configuration for VCR
346
+ let globalVCROptions: VCROptions = {};
347
+
348
+ export function configureVCR(options: VCROptions) {
349
+ globalVCROptions = { ...globalVCROptions, ...options };
350
+ }
351
+
352
+ export function resetVCRConfig(): void {
353
+ globalVCROptions = {};
354
+ }
355
+
356
+ /**
357
+ * Organizes cassettes by hierarchical subdirectories.
358
+ */
359
+ export function describeVCR(name: string, fn: () => void | Promise<void>): void | Promise<void> {
360
+ currentVCRScopes.push(name);
361
+
362
+ const finish = () => {
363
+ currentVCRScopes.pop();
364
+ };
365
+
366
+ try {
367
+ const result = fn();
368
+ if (result instanceof Promise) {
369
+ return result.finally(finish);
370
+ }
371
+ finish();
372
+ return result;
373
+ } catch (err) {
374
+ finish();
375
+ throw err;
376
+ }
377
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "custom-scrub-config",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:37:31.163Z",
6
+ "duration": 1
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "status of [REDACTED]"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to status of [REDACTED]",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "defaults-plus-custom",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:37:31.166Z",
6
+ "duration": 1
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "key [REDACTED] plus custom_field"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to key [REDACTED] plus custom_field",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "explicit-sugar-test",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:42:20.385Z",
6
+ "duration": 6
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "Explicit test"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to Explicit test",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "feature-1-vcr",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:42:20.369Z",
6
+ "duration": 6
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "Record me"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to Record me",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "global-config-keys",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:39:54.212Z",
6
+ "duration": 9
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "regular question"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to regular question",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "global-config-merge",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T08:26:06.881Z",
6
+ "duration": 1
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "regular question"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to regular question",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "global-config-patterns",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:39:54.226Z",
6
+ "duration": 11
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "Status of [REDACTED]"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to Status of [REDACTED]",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "global-config-reset",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:39:54.244Z",
6
+ "duration": 8
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "to_reset should not be redacted"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to to_reset should not be redacted",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "global-config-test",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T10:42:20.398Z",
6
+ "duration": 3
7
+ },
8
+ "interactions": [
9
+ {
10
+ "method": "chat",
11
+ "request": {
12
+ "model": "mock-model",
13
+ "messages": [
14
+ {
15
+ "role": "user",
16
+ "content": "global test"
17
+ }
18
+ ],
19
+ "max_tokens": 4096,
20
+ "headers": {},
21
+ "requestTimeout": 30000
22
+ },
23
+ "response": {
24
+ "content": "Response to global test",
25
+ "usage": {
26
+ "input_tokens": 10,
27
+ "output_tokens": 10,
28
+ "total_tokens": 20
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "streaming-chunks",
3
+ "version": "1.0",
4
+ "metadata": {
5
+ "recordedAt": "2026-01-26T00:00:00.000Z"
6
+ },
7
+ "interactions": [
8
+ {
9
+ "method": "chat",
10
+ "request": { "messages": [{ "role": "user", "content": "Test" }] },
11
+ "response": {
12
+ "content": "Response to Test",
13
+ "tool_calls": [],
14
+ "usage": { "prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10 }
15
+ }
16
+ }
17
+ ]
18
+ }