@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.
- package/CHANGELOG.md +8 -0
- package/README.md +541 -0
- package/dist/Mocker.d.ts +58 -0
- package/dist/Mocker.d.ts.map +1 -0
- package/dist/Mocker.js +247 -0
- package/dist/Scrubber.d.ts +18 -0
- package/dist/Scrubber.d.ts.map +1 -0
- package/dist/Scrubber.js +68 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/vcr.d.ts +57 -0
- package/dist/vcr.d.ts.map +1 -0
- package/dist/vcr.js +291 -0
- package/package.json +19 -0
- package/src/Mocker.ts +311 -0
- package/src/Scrubber.ts +85 -0
- package/src/index.ts +2 -0
- package/src/vcr.ts +377 -0
- package/test/cassettes/custom-scrub-config.json +33 -0
- package/test/cassettes/defaults-plus-custom.json +33 -0
- package/test/cassettes/explicit-sugar-test.json +33 -0
- package/test/cassettes/feature-1-vcr.json +33 -0
- package/test/cassettes/global-config-keys.json +33 -0
- package/test/cassettes/global-config-merge.json +33 -0
- package/test/cassettes/global-config-patterns.json +33 -0
- package/test/cassettes/global-config-reset.json +33 -0
- package/test/cassettes/global-config-test.json +33 -0
- package/test/cassettes/streaming-chunks.json +18 -0
- package/test/cassettes/testunitdxtestts-vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +33 -0
- package/test/cassettes/vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +28 -0
- package/test/cassettes/vcr-streaming.json +17 -0
- package/test/helpers/MockProvider.ts +75 -0
- package/test/unit/ci.test.ts +36 -0
- package/test/unit/dx.test.ts +86 -0
- package/test/unit/mocker-debug.test.ts +68 -0
- package/test/unit/mocker.test.ts +46 -0
- package/test/unit/multimodal.test.ts +46 -0
- package/test/unit/scoping.test.ts +54 -0
- package/test/unit/scrubbing.test.ts +110 -0
- package/test/unit/streaming.test.ts +51 -0
- package/test/unit/strict-mode.test.ts +112 -0
- package/test/unit/tools.test.ts +58 -0
- package/test/unit/vcr-global-config.test.ts +87 -0
- package/test/unit/vcr-mismatch.test.ts +172 -0
- package/test/unit/vcr-passthrough.test.ts +68 -0
- package/test/unit/vcr-streaming.test.ts +86 -0
- package/test/unit/vcr.test.ts +34 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +12 -0
package/dist/vcr.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { providerRegistry } from "@node-llm/core";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Scrubber } from "./Scrubber.js";
|
|
5
|
+
// Internal state for nested scoping (Feature 12)
|
|
6
|
+
const currentVCRScopes = [];
|
|
7
|
+
// Try to import Vitest's expect to get test state
|
|
8
|
+
let vitestExpect;
|
|
9
|
+
try {
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
import("vitest").then((m) => {
|
|
12
|
+
vitestExpect = m.expect;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Not in vitest env
|
|
17
|
+
}
|
|
18
|
+
export class VCR {
|
|
19
|
+
cassette;
|
|
20
|
+
interactionIndex = 0;
|
|
21
|
+
mode;
|
|
22
|
+
filePath;
|
|
23
|
+
scrubber;
|
|
24
|
+
recordStartTime = 0;
|
|
25
|
+
constructor(name, options = {}) {
|
|
26
|
+
// 1. Merge Global Defaults
|
|
27
|
+
// Explicitly merge arrays to avoid overwriting global sensitive keys/patterns
|
|
28
|
+
const mergedOptions = {
|
|
29
|
+
...globalVCROptions,
|
|
30
|
+
...options,
|
|
31
|
+
sensitivePatterns: [
|
|
32
|
+
...(globalVCROptions.sensitivePatterns || []),
|
|
33
|
+
...(options.sensitivePatterns || [])
|
|
34
|
+
],
|
|
35
|
+
sensitiveKeys: [...(globalVCROptions.sensitiveKeys || []), ...(options.sensitiveKeys || [])]
|
|
36
|
+
};
|
|
37
|
+
// 2. Resolve Base Directory (Env -> Option -> Default)
|
|
38
|
+
// Rails-inspired organization: cassettes belong inside the test folder
|
|
39
|
+
const baseDir = mergedOptions.cassettesDir || process.env.VCR_CASSETTE_DIR || "test/cassettes";
|
|
40
|
+
// 2. Resolve Hierarchical Scopes
|
|
41
|
+
const scopes = [];
|
|
42
|
+
if (Array.isArray(mergedOptions.scope)) {
|
|
43
|
+
scopes.push(...mergedOptions.scope);
|
|
44
|
+
}
|
|
45
|
+
else if (mergedOptions.scope) {
|
|
46
|
+
scopes.push(mergedOptions.scope);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
scopes.push(...currentVCRScopes);
|
|
50
|
+
}
|
|
51
|
+
// 3. Construct Final Directory Path
|
|
52
|
+
const targetDir = path.join(baseDir, ...scopes.map((s) => this.slugify(s)));
|
|
53
|
+
// Robust path resolution: Never join CWD if the target is already absolute
|
|
54
|
+
if (path.isAbsolute(targetDir)) {
|
|
55
|
+
this.filePath = path.join(targetDir, `${this.slugify(name)}.json`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
this.filePath = path.join(process.cwd(), targetDir, `${this.slugify(name)}.json`);
|
|
59
|
+
}
|
|
60
|
+
const initialMode = mergedOptions.mode || process.env.VCR_MODE || "auto";
|
|
61
|
+
const isCI = !!process.env.CI;
|
|
62
|
+
const exists = fs.existsSync(this.filePath);
|
|
63
|
+
// CI Enforcement
|
|
64
|
+
if (isCI) {
|
|
65
|
+
if (initialMode === "record") {
|
|
66
|
+
throw new Error(`VCR[${name}]: Recording cassettes is not allowed in CI.`);
|
|
67
|
+
}
|
|
68
|
+
if (initialMode === "auto" && !exists) {
|
|
69
|
+
throw new Error(`VCR[${name}]: Cassette missing in CI. Run locally to generate ${this.filePath}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Mode Resolution:
|
|
73
|
+
// - "record": Always record (overwrites existing cassette)
|
|
74
|
+
// - "replay": Always replay (fails if cassette missing)
|
|
75
|
+
// - "auto": Replay if exists, otherwise FAIL (requires explicit record)
|
|
76
|
+
// - "passthrough": No VCR, make real calls
|
|
77
|
+
if (initialMode === "auto") {
|
|
78
|
+
if (exists) {
|
|
79
|
+
this.mode = "replay";
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
throw new Error(`VCR[${name}]: Cassette not found at ${this.filePath}. ` +
|
|
83
|
+
`Use mode: "record" to create it, then switch back to "auto" or "replay".`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
this.mode = initialMode;
|
|
88
|
+
}
|
|
89
|
+
this.scrubber = new Scrubber({
|
|
90
|
+
customScrubber: mergedOptions.scrub,
|
|
91
|
+
sensitivePatterns: mergedOptions.sensitivePatterns,
|
|
92
|
+
sensitiveKeys: mergedOptions.sensitiveKeys
|
|
93
|
+
});
|
|
94
|
+
if (this.mode === "replay") {
|
|
95
|
+
if (!exists) {
|
|
96
|
+
throw new Error(`VCR[${name}]: Cassette not found at ${this.filePath}`);
|
|
97
|
+
}
|
|
98
|
+
this.cassette = JSON.parse(fs.readFileSync(this.filePath, "utf-8"));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this.cassette = {
|
|
102
|
+
name,
|
|
103
|
+
version: "1.0",
|
|
104
|
+
metadata: {
|
|
105
|
+
recordedAt: new Date().toISOString()
|
|
106
|
+
},
|
|
107
|
+
interactions: []
|
|
108
|
+
};
|
|
109
|
+
this.recordStartTime = Date.now();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
get currentMode() {
|
|
113
|
+
return this.mode;
|
|
114
|
+
}
|
|
115
|
+
async stop() {
|
|
116
|
+
if (this.mode === "record" && this.interactionsCount > 0) {
|
|
117
|
+
const dir = path.dirname(this.filePath);
|
|
118
|
+
if (!fs.existsSync(dir)) {
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
// Update metadata with duration
|
|
122
|
+
const duration = Date.now() - this.recordStartTime;
|
|
123
|
+
if (this.cassette.metadata) {
|
|
124
|
+
this.cassette.metadata.duration = duration;
|
|
125
|
+
}
|
|
126
|
+
fs.writeFileSync(this.filePath, JSON.stringify(this.cassette, null, 2));
|
|
127
|
+
}
|
|
128
|
+
providerRegistry.setInterceptor(undefined);
|
|
129
|
+
}
|
|
130
|
+
get interactionsCount() {
|
|
131
|
+
return this.cassette.interactions.length;
|
|
132
|
+
}
|
|
133
|
+
async execute(method, originalMethod, request) {
|
|
134
|
+
if (this.mode === "replay") {
|
|
135
|
+
const interaction = this.cassette.interactions[this.interactionIndex++];
|
|
136
|
+
if (!interaction) {
|
|
137
|
+
throw new Error(`VCR[${this.cassette.name}]: No more interactions for ${method}`);
|
|
138
|
+
}
|
|
139
|
+
return interaction.response;
|
|
140
|
+
}
|
|
141
|
+
const response = await originalMethod(request);
|
|
142
|
+
if (this.mode === "record") {
|
|
143
|
+
const interaction = this.scrubber.scrub({
|
|
144
|
+
method,
|
|
145
|
+
request: this.clone(request),
|
|
146
|
+
response: this.clone(response)
|
|
147
|
+
});
|
|
148
|
+
this.cassette.interactions.push(interaction);
|
|
149
|
+
}
|
|
150
|
+
return response;
|
|
151
|
+
}
|
|
152
|
+
async *executeStream(method, originalMethod, request) {
|
|
153
|
+
if (this.mode === "replay") {
|
|
154
|
+
const interaction = this.cassette.interactions[this.interactionIndex++];
|
|
155
|
+
if (!interaction || !interaction.chunks) {
|
|
156
|
+
throw new Error(`VCR[${this.cassette.name}]: No streaming interactions found`);
|
|
157
|
+
}
|
|
158
|
+
for (const chunk of interaction.chunks) {
|
|
159
|
+
yield chunk;
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const stream = originalMethod(request);
|
|
164
|
+
const chunks = [];
|
|
165
|
+
for await (const chunk of stream) {
|
|
166
|
+
if (this.mode === "record")
|
|
167
|
+
chunks.push(this.clone(chunk));
|
|
168
|
+
yield chunk;
|
|
169
|
+
}
|
|
170
|
+
if (this.mode === "record") {
|
|
171
|
+
const interaction = this.scrubber.scrub({
|
|
172
|
+
method,
|
|
173
|
+
request: this.clone(request),
|
|
174
|
+
response: null,
|
|
175
|
+
chunks: chunks.map((c) => this.clone(c))
|
|
176
|
+
});
|
|
177
|
+
this.cassette.interactions.push(interaction);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
clone(obj) {
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(JSON.stringify(obj));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return obj;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
slugify(text) {
|
|
189
|
+
return text
|
|
190
|
+
.toString()
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.trim()
|
|
193
|
+
.replace(/\s+/g, "-")
|
|
194
|
+
.replace(/[^\w-]+/g, "")
|
|
195
|
+
.replace(/--+/g, "-");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const EXECUTION_METHODS = ["chat", "stream", "paint", "transcribe", "moderate", "embed"];
|
|
199
|
+
export function setupVCR(name, options = {}) {
|
|
200
|
+
const vcr = new VCR(name, options);
|
|
201
|
+
providerRegistry.setInterceptor((provider) => {
|
|
202
|
+
return new Proxy(provider, {
|
|
203
|
+
get(target, prop, receiver) {
|
|
204
|
+
const originalValue = Reflect.get(target, prop, receiver);
|
|
205
|
+
const method = prop.toString();
|
|
206
|
+
if (typeof originalValue === "function" && EXECUTION_METHODS.includes(method)) {
|
|
207
|
+
return function (...args) {
|
|
208
|
+
if (method === "stream") {
|
|
209
|
+
return vcr.executeStream(method, originalValue.bind(target), args[0]);
|
|
210
|
+
}
|
|
211
|
+
return vcr.execute(method, originalValue.bind(target), args[0]);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return originalValue;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
return vcr;
|
|
219
|
+
}
|
|
220
|
+
export function withVCR(...args) {
|
|
221
|
+
// Capture scopes at initialization time
|
|
222
|
+
const capturedScopes = [...currentVCRScopes];
|
|
223
|
+
return async function () {
|
|
224
|
+
let name;
|
|
225
|
+
let options = {};
|
|
226
|
+
let fn;
|
|
227
|
+
if (typeof args[0] === "function") {
|
|
228
|
+
fn = args[0];
|
|
229
|
+
}
|
|
230
|
+
else if (typeof args[0] === "string") {
|
|
231
|
+
name = args[0];
|
|
232
|
+
if (typeof args[1] === "function") {
|
|
233
|
+
fn = args[1];
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
options = args[1] || {};
|
|
237
|
+
fn = args[2];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
options = args[0] || {};
|
|
242
|
+
fn = args[1];
|
|
243
|
+
}
|
|
244
|
+
// Pass captured inherited scopes if not explicitly overridden
|
|
245
|
+
if (capturedScopes.length > 0 && !options.scope) {
|
|
246
|
+
options.scope = capturedScopes;
|
|
247
|
+
}
|
|
248
|
+
if (!name && vitestExpect) {
|
|
249
|
+
const state = vitestExpect.getState();
|
|
250
|
+
name = state.currentTestName || "unnamed-test";
|
|
251
|
+
}
|
|
252
|
+
if (!name)
|
|
253
|
+
throw new Error("VCR: Could not determine cassette name.");
|
|
254
|
+
const vcr = setupVCR(name, options);
|
|
255
|
+
try {
|
|
256
|
+
await fn();
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await vcr.stop();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// Global configuration for VCR
|
|
264
|
+
let globalVCROptions = {};
|
|
265
|
+
export function configureVCR(options) {
|
|
266
|
+
globalVCROptions = { ...globalVCROptions, ...options };
|
|
267
|
+
}
|
|
268
|
+
export function resetVCRConfig() {
|
|
269
|
+
globalVCROptions = {};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Organizes cassettes by hierarchical subdirectories.
|
|
273
|
+
*/
|
|
274
|
+
export function describeVCR(name, fn) {
|
|
275
|
+
currentVCRScopes.push(name);
|
|
276
|
+
const finish = () => {
|
|
277
|
+
currentVCRScopes.pop();
|
|
278
|
+
};
|
|
279
|
+
try {
|
|
280
|
+
const result = fn();
|
|
281
|
+
if (result instanceof Promise) {
|
|
282
|
+
return result.finally(finish);
|
|
283
|
+
}
|
|
284
|
+
finish();
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
finish();
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@node-llm/testing",
|
|
3
|
+
"version": "0.01.0",
|
|
4
|
+
"description": "Deterministic testing for NodeLLM powered AI systems",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:record": "VCR_MODE=record vitest run",
|
|
11
|
+
"test:vitest": "vitest run"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@node-llm/core": "workspace:*",
|
|
16
|
+
"vitest": "^1.0.0",
|
|
17
|
+
"typescript": "^5.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/Mocker.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Provider,
|
|
3
|
+
providerRegistry,
|
|
4
|
+
ChatResponse,
|
|
5
|
+
EmbeddingResponse,
|
|
6
|
+
ChatRequest,
|
|
7
|
+
EmbeddingRequest,
|
|
8
|
+
ImageRequest,
|
|
9
|
+
TranscriptionRequest,
|
|
10
|
+
ModerationRequest,
|
|
11
|
+
ImageResponse,
|
|
12
|
+
TranscriptionResponse,
|
|
13
|
+
ModerationResponse,
|
|
14
|
+
ChatChunk,
|
|
15
|
+
ToolCall,
|
|
16
|
+
ModerationResult,
|
|
17
|
+
MessageContent
|
|
18
|
+
} from "@node-llm/core";
|
|
19
|
+
|
|
20
|
+
export interface MockResponse {
|
|
21
|
+
content?: string | null;
|
|
22
|
+
tool_calls?: ToolCall[];
|
|
23
|
+
usage?: {
|
|
24
|
+
input_tokens: number;
|
|
25
|
+
output_tokens: number;
|
|
26
|
+
total_tokens: number;
|
|
27
|
+
};
|
|
28
|
+
error?: Error;
|
|
29
|
+
finish_reason?: string | null;
|
|
30
|
+
chunks?: string[] | ChatChunk[];
|
|
31
|
+
vectors?: number[][];
|
|
32
|
+
url?: string;
|
|
33
|
+
data?: string;
|
|
34
|
+
text?: string;
|
|
35
|
+
results?: ModerationResult[];
|
|
36
|
+
revised_prompt?: string;
|
|
37
|
+
id?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MockMatcher = (request: unknown) => boolean;
|
|
41
|
+
|
|
42
|
+
export interface MockDefinition {
|
|
43
|
+
method: string;
|
|
44
|
+
match: MockMatcher;
|
|
45
|
+
response: MockResponse | ((request: unknown) => MockResponse);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Debug information about defined mocks.
|
|
50
|
+
*/
|
|
51
|
+
export interface MockerDebugInfo {
|
|
52
|
+
totalMocks: number;
|
|
53
|
+
methods: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const EXECUTION_METHODS = ["chat", "stream", "paint", "transcribe", "moderate", "embed"];
|
|
57
|
+
|
|
58
|
+
export class Mocker {
|
|
59
|
+
private mocks: MockDefinition[] = [];
|
|
60
|
+
public strict = false;
|
|
61
|
+
|
|
62
|
+
constructor() {
|
|
63
|
+
this.setupInterceptor();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public chat(query?: string | RegExp): this {
|
|
67
|
+
return this.addMock("chat", (req: unknown) => {
|
|
68
|
+
const chatReq = req as ChatRequest;
|
|
69
|
+
if (!query) return true;
|
|
70
|
+
const lastMessage = [...chatReq.messages].reverse().find((m) => m.role === "user");
|
|
71
|
+
if (!lastMessage) return false;
|
|
72
|
+
|
|
73
|
+
const content = this.getContentString(lastMessage.content);
|
|
74
|
+
if (typeof query === "string") return content === query;
|
|
75
|
+
if (query instanceof RegExp) return typeof content === "string" && query.test(content);
|
|
76
|
+
return false;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public stream(chunks: string[] | ChatChunk[]): this {
|
|
81
|
+
const lastMock = this.mocks[this.mocks.length - 1];
|
|
82
|
+
if (!lastMock || (lastMock.method !== "chat" && lastMock.method !== "stream")) {
|
|
83
|
+
throw new Error("Mocker: .stream() must follow a .chat() or .addMock('stream') definition.");
|
|
84
|
+
}
|
|
85
|
+
lastMock.method = "stream";
|
|
86
|
+
lastMock.response = { chunks };
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public placeholder(query: string | RegExp): this {
|
|
91
|
+
return this.addMock("chat", (req: unknown) => {
|
|
92
|
+
const chatReq = req as ChatRequest;
|
|
93
|
+
return chatReq.messages.some((m) => {
|
|
94
|
+
const content = this.getContentString(m.content);
|
|
95
|
+
if (typeof query === "string") return content === query;
|
|
96
|
+
return typeof content === "string" && query.test(content);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public callsTool(name: string, args: Record<string, unknown> = {}): this {
|
|
102
|
+
const lastMock = this.mocks[this.mocks.length - 1];
|
|
103
|
+
if (!lastMock || lastMock.method !== "chat") {
|
|
104
|
+
throw new Error("Mocker: .callsTool() must follow a .chat() definition.");
|
|
105
|
+
}
|
|
106
|
+
lastMock.response = {
|
|
107
|
+
content: null,
|
|
108
|
+
tool_calls: [
|
|
109
|
+
{
|
|
110
|
+
id: `call_${Math.random().toString(36).slice(2, 9)}`,
|
|
111
|
+
type: "function",
|
|
112
|
+
function: { name, arguments: JSON.stringify(args) }
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
finish_reason: "tool_calls"
|
|
116
|
+
};
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public embed(input?: string | string[]): this {
|
|
121
|
+
return this.addMock("embed", (req: unknown) => {
|
|
122
|
+
const embReq = req as EmbeddingRequest;
|
|
123
|
+
if (!input) return true;
|
|
124
|
+
return JSON.stringify(embReq.input) === JSON.stringify(input);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public paint(prompt?: string | RegExp): this {
|
|
129
|
+
return this.addMock("paint", (req: unknown) => {
|
|
130
|
+
const paintReq = req as ImageRequest;
|
|
131
|
+
if (!prompt) return true;
|
|
132
|
+
if (typeof prompt === "string") return paintReq.prompt === prompt;
|
|
133
|
+
return prompt.test(paintReq.prompt);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public transcribe(file?: string | RegExp): this {
|
|
138
|
+
return this.addMock("transcribe", (req: unknown) => {
|
|
139
|
+
const transReq = req as TranscriptionRequest;
|
|
140
|
+
if (!file) return true;
|
|
141
|
+
if (typeof file === "string") return transReq.file === file;
|
|
142
|
+
return file.test(transReq.file);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public moderate(input?: string | string[] | RegExp): this {
|
|
147
|
+
return this.addMock("moderate", (req: unknown) => {
|
|
148
|
+
const modReq = req as ModerationRequest;
|
|
149
|
+
if (!input) return true;
|
|
150
|
+
const content = Array.isArray(modReq.input) ? modReq.input.join(" ") : modReq.input;
|
|
151
|
+
if (input instanceof RegExp) return input.test(content);
|
|
152
|
+
return JSON.stringify(modReq.input) === JSON.stringify(input);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public respond(response: string | MockResponse | ((req: unknown) => MockResponse)): this {
|
|
157
|
+
const lastMock = this.mocks[this.mocks.length - 1];
|
|
158
|
+
if (!lastMock) throw new Error("Mocker: No mock definition started.");
|
|
159
|
+
if (typeof response === "string") {
|
|
160
|
+
lastMock.response = { content: response, text: response };
|
|
161
|
+
} else {
|
|
162
|
+
lastMock.response = response;
|
|
163
|
+
}
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Returns debug information about defined mocks.
|
|
169
|
+
* Useful for troubleshooting what mocks are defined.
|
|
170
|
+
*/
|
|
171
|
+
public getDebugInfo(): MockerDebugInfo {
|
|
172
|
+
const methods = this.mocks.map((m) => m.method);
|
|
173
|
+
return {
|
|
174
|
+
totalMocks: this.mocks.length,
|
|
175
|
+
methods: [...new Set(methods)]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
public clear(): void {
|
|
180
|
+
this.mocks = [];
|
|
181
|
+
providerRegistry.setInterceptor(undefined);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private addMock(method: string, matcher: MockMatcher): this {
|
|
185
|
+
this.mocks.push({ method, match: matcher, response: { content: "Mock response" } });
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private getContentString(content: MessageContent | null | undefined | unknown): string | null {
|
|
190
|
+
if (content === null || content === undefined) return null;
|
|
191
|
+
if (typeof content === "string") return content;
|
|
192
|
+
if (Array.isArray(content)) {
|
|
193
|
+
return content.map((part) => (part.type === "text" ? part.text : "")).join("");
|
|
194
|
+
}
|
|
195
|
+
return String(content);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private setupInterceptor(): void {
|
|
199
|
+
providerRegistry.setInterceptor((provider: Provider) => {
|
|
200
|
+
return new Proxy(provider, {
|
|
201
|
+
get: (target, prop) => {
|
|
202
|
+
const originalValue = Reflect.get(target, prop);
|
|
203
|
+
const methodName = prop.toString();
|
|
204
|
+
|
|
205
|
+
if (methodName === "id") return target.id;
|
|
206
|
+
|
|
207
|
+
if (EXECUTION_METHODS.includes(methodName)) {
|
|
208
|
+
if (methodName === "stream") {
|
|
209
|
+
return async function* (this: Mocker, request: ChatRequest) {
|
|
210
|
+
const matchingMocks = this.mocks.filter(
|
|
211
|
+
(m) => m.method === methodName && m.match(request)
|
|
212
|
+
);
|
|
213
|
+
const mock = matchingMocks[matchingMocks.length - 1];
|
|
214
|
+
if (mock) {
|
|
215
|
+
const res =
|
|
216
|
+
typeof mock.response === "function" ? mock.response(request) : mock.response;
|
|
217
|
+
if (res.error) throw res.error;
|
|
218
|
+
const chunks = res.chunks || [];
|
|
219
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
220
|
+
const chunk = chunks[i];
|
|
221
|
+
yield typeof chunk === "string"
|
|
222
|
+
? { content: chunk, done: i === chunks.length - 1 }
|
|
223
|
+
: chunk;
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (this.strict) throw new Error("Mocker: Unexpected LLM call to 'stream'");
|
|
228
|
+
const original = originalValue
|
|
229
|
+
? (originalValue as any).apply(target, [request])
|
|
230
|
+
: undefined;
|
|
231
|
+
if (original) yield* original;
|
|
232
|
+
}.bind(this);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Promise-based methods
|
|
236
|
+
return (async (request: unknown) => {
|
|
237
|
+
const matchingMocks = this.mocks.filter(
|
|
238
|
+
(m) => m.method === methodName && m.match(request)
|
|
239
|
+
);
|
|
240
|
+
const mock = matchingMocks[matchingMocks.length - 1];
|
|
241
|
+
|
|
242
|
+
if (mock) {
|
|
243
|
+
const res =
|
|
244
|
+
typeof mock.response === "function" ? mock.response(request) : mock.response;
|
|
245
|
+
if (res.error) throw res.error;
|
|
246
|
+
|
|
247
|
+
switch (methodName) {
|
|
248
|
+
case "chat": {
|
|
249
|
+
return {
|
|
250
|
+
content:
|
|
251
|
+
res.content !== undefined && res.content !== null
|
|
252
|
+
? String(res.content)
|
|
253
|
+
: null,
|
|
254
|
+
tool_calls: res.tool_calls || [],
|
|
255
|
+
usage: res.usage || { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
|
|
256
|
+
finish_reason:
|
|
257
|
+
res.finish_reason || (res.tool_calls?.length ? "tool_calls" : "stop")
|
|
258
|
+
} as ChatResponse;
|
|
259
|
+
}
|
|
260
|
+
case "embed": {
|
|
261
|
+
const embReq = request as EmbeddingRequest;
|
|
262
|
+
return {
|
|
263
|
+
vectors: res.vectors || [[0.1, 0.2, 0.3]],
|
|
264
|
+
model: embReq.model || "mock-embed",
|
|
265
|
+
input_tokens: 10,
|
|
266
|
+
dimensions: res.vectors?.[0]?.length || 3
|
|
267
|
+
} as EmbeddingResponse;
|
|
268
|
+
}
|
|
269
|
+
case "paint": {
|
|
270
|
+
const paintReq = request as ImageRequest;
|
|
271
|
+
return {
|
|
272
|
+
url: res.url || "http://mock.com/image.png",
|
|
273
|
+
revised_prompt: res.revised_prompt || paintReq.prompt
|
|
274
|
+
} as ImageResponse;
|
|
275
|
+
}
|
|
276
|
+
case "transcribe": {
|
|
277
|
+
const transReq = request as TranscriptionRequest;
|
|
278
|
+
return {
|
|
279
|
+
text: res.text || "Mock transcript",
|
|
280
|
+
model: transReq.model || "mock-whisper"
|
|
281
|
+
} as TranscriptionResponse;
|
|
282
|
+
}
|
|
283
|
+
case "moderate": {
|
|
284
|
+
const modReq = request as ModerationRequest;
|
|
285
|
+
return {
|
|
286
|
+
id: res.id || "mod-123",
|
|
287
|
+
model: modReq.model || "mock-mod",
|
|
288
|
+
results: res.results || [
|
|
289
|
+
{ flagged: false, categories: {}, category_scores: {} }
|
|
290
|
+
]
|
|
291
|
+
} as ModerationResponse;
|
|
292
|
+
}
|
|
293
|
+
default:
|
|
294
|
+
return res;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (this.strict) throw new Error(`Mocker: Unexpected LLM call to '${methodName}'`);
|
|
299
|
+
return originalValue ? (originalValue as any).apply(target, [request]) : undefined;
|
|
300
|
+
}).bind(this);
|
|
301
|
+
}
|
|
302
|
+
return originalValue;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function mockLLM() {
|
|
310
|
+
return new Mocker();
|
|
311
|
+
}
|
package/src/Scrubber.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export const DEFAULT_SECRET_PATTERNS = [
|
|
2
|
+
/sk-[a-zA-Z0-9]{20,}/g, // OpenAI/Anthropic likely patterns
|
|
3
|
+
/x-[a-zA-Z0-9]{20,}/g, // Generic API keys
|
|
4
|
+
/[a-zA-Z0-9]{32,}/g // Long hashes/keys
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
export interface ScrubberOptions {
|
|
8
|
+
customScrubber?: (data: unknown) => unknown;
|
|
9
|
+
sensitivePatterns?: RegExp[];
|
|
10
|
+
sensitiveKeys?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Scrubber {
|
|
14
|
+
private customScrubber?: (data: unknown) => unknown;
|
|
15
|
+
private sensitivePatterns: RegExp[];
|
|
16
|
+
private sensitiveKeys: Set<string>;
|
|
17
|
+
|
|
18
|
+
constructor(options: ScrubberOptions = {}) {
|
|
19
|
+
this.customScrubber = options.customScrubber;
|
|
20
|
+
this.sensitivePatterns = [...DEFAULT_SECRET_PATTERNS, ...(options.sensitivePatterns || [])];
|
|
21
|
+
this.sensitiveKeys = new Set([
|
|
22
|
+
"key",
|
|
23
|
+
"api_key",
|
|
24
|
+
"token",
|
|
25
|
+
"auth",
|
|
26
|
+
"authorization",
|
|
27
|
+
...(options.sensitiveKeys || []).map((k) => k.toLowerCase())
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Applies scrubbing to the data using default patterns and custom logic.
|
|
33
|
+
*/
|
|
34
|
+
public scrub(data: unknown): unknown {
|
|
35
|
+
// 1. Perform deep regex scrubbing and key-based scrubbing
|
|
36
|
+
let result = this.deepScrub(data);
|
|
37
|
+
|
|
38
|
+
// 2. Run custom hook on the scrubbed data if provided
|
|
39
|
+
if (this.customScrubber) {
|
|
40
|
+
result = this.customScrubber(result);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private deepScrub(val: unknown): unknown {
|
|
47
|
+
if (typeof val === "string") {
|
|
48
|
+
let scrubbed = val;
|
|
49
|
+
for (const pattern of this.sensitivePatterns) {
|
|
50
|
+
scrubbed = scrubbed.replace(pattern, "[REDACTED]");
|
|
51
|
+
}
|
|
52
|
+
return scrubbed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(val)) {
|
|
56
|
+
return val.map((v) => this.deepScrub(v));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (val !== null && typeof val === "object") {
|
|
60
|
+
const obj = val as Record<string, unknown>;
|
|
61
|
+
const newObj: Record<string, unknown> = {};
|
|
62
|
+
|
|
63
|
+
for (const key in obj) {
|
|
64
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
65
|
+
|
|
66
|
+
const lowerKey = key.toLowerCase();
|
|
67
|
+
const value = obj[key];
|
|
68
|
+
|
|
69
|
+
// SENSITIVE KEY LOGIC:
|
|
70
|
+
// Only redact if the key looks like a credential AND the value is a string.
|
|
71
|
+
// We don't want to redact 'total_tokens' or 'input_tokens' which are numbers.
|
|
72
|
+
const isCredentialKey = this.sensitiveKeys.has(lowerKey);
|
|
73
|
+
|
|
74
|
+
if (isCredentialKey && typeof value === "string") {
|
|
75
|
+
newObj[key] = "[REDACTED]";
|
|
76
|
+
} else {
|
|
77
|
+
newObj[key] = this.deepScrub(value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return newObj;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return val;
|
|
84
|
+
}
|
|
85
|
+
}
|