@framers/agentos-ext-ml-classifiers 0.1.0 → 0.2.1
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 +18 -0
- package/dist/MLClassifierGuardrail.d.ts +88 -117
- package/dist/MLClassifierGuardrail.d.ts.map +1 -1
- package/dist/MLClassifierGuardrail.js +255 -264
- package/dist/MLClassifierGuardrail.js.map +1 -1
- package/dist/classifiers/InjectionClassifier.d.ts +1 -1
- package/dist/classifiers/InjectionClassifier.d.ts.map +1 -1
- package/dist/classifiers/JailbreakClassifier.d.ts +1 -1
- package/dist/classifiers/JailbreakClassifier.d.ts.map +1 -1
- package/dist/classifiers/ToxicityClassifier.d.ts +1 -1
- package/dist/classifiers/ToxicityClassifier.d.ts.map +1 -1
- package/dist/classifiers/WorkerClassifierProxy.d.ts +1 -1
- package/dist/classifiers/WorkerClassifierProxy.d.ts.map +1 -1
- package/dist/index.d.ts +16 -90
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -306
- package/dist/index.js.map +1 -1
- package/dist/keyword-classifier.d.ts +26 -0
- package/dist/keyword-classifier.d.ts.map +1 -0
- package/dist/keyword-classifier.js +113 -0
- package/dist/keyword-classifier.js.map +1 -0
- package/dist/llm-classifier.d.ts +27 -0
- package/dist/llm-classifier.d.ts.map +1 -0
- package/dist/llm-classifier.js +129 -0
- package/dist/llm-classifier.js.map +1 -0
- package/dist/tools/ClassifyContentTool.d.ts +53 -80
- package/dist/tools/ClassifyContentTool.d.ts.map +1 -1
- package/dist/tools/ClassifyContentTool.js +52 -103
- package/dist/tools/ClassifyContentTool.js.map +1 -1
- package/dist/types.d.ts +77 -277
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -55
- package/dist/types.js.map +1 -1
- package/package.json +10 -16
- package/src/MLClassifierGuardrail.ts +279 -316
- package/src/index.ts +35 -339
- package/src/keyword-classifier.ts +130 -0
- package/src/llm-classifier.ts +163 -0
- package/src/tools/ClassifyContentTool.ts +75 -132
- package/src/types.ts +78 -325
- package/test/ClassifierOrchestrator.spec.ts +365 -0
- package/test/ClassifyContentTool.spec.ts +226 -0
- package/test/InjectionClassifier.spec.ts +263 -0
- package/test/JailbreakClassifier.spec.ts +295 -0
- package/test/MLClassifierGuardrail.spec.ts +486 -0
- package/test/SlidingWindowBuffer.spec.ts +391 -0
- package/test/ToxicityClassifier.spec.ts +268 -0
- package/test/WorkerClassifierProxy.spec.ts +303 -0
- package/test/index.spec.ts +431 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +24 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unit tests for {@link WorkerClassifierProxy}.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the proxy's routing logic and IContentClassifier
|
|
5
|
+
* contract without relying on real Web Workers or model weights.
|
|
6
|
+
*
|
|
7
|
+
* Test coverage:
|
|
8
|
+
* 1. Falls back to main-thread when `typeof Worker === 'undefined'`
|
|
9
|
+
* 2. Delegates `classify()` to the wrapped classifier in fallback mode
|
|
10
|
+
* 3. Exposes the same identity properties (`id`, `displayName`, `description`, `modelId`)
|
|
11
|
+
* as the wrapped classifier
|
|
12
|
+
* 4. `isLoaded` reflects the wrapped classifier's state
|
|
13
|
+
* 5. `useWebWorker: false` forces main-thread execution
|
|
14
|
+
* 6. Worker creation failure sets `workerFailed` and falls back gracefully
|
|
15
|
+
* 7. `dispose()` is forwarded to the wrapped classifier
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
19
|
+
import type { ClassificationResult } from '@framers/agentos';
|
|
20
|
+
import type { IContentClassifier } from '../src/IContentClassifier';
|
|
21
|
+
import { WorkerClassifierProxy } from '../src/classifiers/WorkerClassifierProxy';
|
|
22
|
+
import type { BrowserConfig } from '../src/types';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Test fixtures & helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A well-defined classification result used throughout the suite so tests
|
|
30
|
+
* are not coupled to magic literal values.
|
|
31
|
+
*/
|
|
32
|
+
const MOCK_RESULT: ClassificationResult = {
|
|
33
|
+
bestClass: 'toxic',
|
|
34
|
+
confidence: 0.92,
|
|
35
|
+
allScores: [
|
|
36
|
+
{ classLabel: 'toxic', score: 0.92 },
|
|
37
|
+
{ classLabel: 'benign', score: 0.08 },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a minimal mock IContentClassifier with all required fields.
|
|
43
|
+
*
|
|
44
|
+
* @param overrides - Optional partial overrides for specific fields.
|
|
45
|
+
* @returns A mock classifier with controllable `classify()` and `dispose()`.
|
|
46
|
+
*/
|
|
47
|
+
function makeWrapped(
|
|
48
|
+
overrides: Partial<IContentClassifier> = {},
|
|
49
|
+
): IContentClassifier & { classify: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn> } {
|
|
50
|
+
return {
|
|
51
|
+
id: 'agentos:ml-classifiers:toxicity-pipeline',
|
|
52
|
+
displayName: 'Toxicity Pipeline',
|
|
53
|
+
description: 'Detects toxic content.',
|
|
54
|
+
modelId: 'unitary/toxic-bert',
|
|
55
|
+
isLoaded: false,
|
|
56
|
+
classify: vi.fn(async (_text: string): Promise<ClassificationResult> => MOCK_RESULT),
|
|
57
|
+
dispose: vi.fn(async (): Promise<void> => {}),
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Tests
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe('WorkerClassifierProxy', () => {
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
// 1. Identity properties delegated from wrapped classifier
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('IContentClassifier identity properties', () => {
|
|
72
|
+
it('exposes the same id as the wrapped classifier', () => {
|
|
73
|
+
const wrapped = makeWrapped();
|
|
74
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
75
|
+
|
|
76
|
+
expect(proxy.id).toBe(wrapped.id);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('exposes the same displayName as the wrapped classifier', () => {
|
|
80
|
+
const wrapped = makeWrapped({ displayName: 'My Custom Classifier' });
|
|
81
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
82
|
+
|
|
83
|
+
expect(proxy.displayName).toBe('My Custom Classifier');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('exposes the same description as the wrapped classifier', () => {
|
|
87
|
+
const wrapped = makeWrapped({ description: 'Detects bad stuff.' });
|
|
88
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
89
|
+
|
|
90
|
+
expect(proxy.description).toBe('Detects bad stuff.');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('exposes the same modelId as the wrapped classifier', () => {
|
|
94
|
+
const wrapped = makeWrapped({ modelId: 'Xenova/custom-model' });
|
|
95
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
96
|
+
|
|
97
|
+
expect(proxy.modelId).toBe('Xenova/custom-model');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// -------------------------------------------------------------------------
|
|
102
|
+
// 2. isLoaded reflects wrapped classifier state
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe('isLoaded', () => {
|
|
106
|
+
it('returns false when wrapped classifier isLoaded is false', () => {
|
|
107
|
+
const wrapped = makeWrapped({ isLoaded: false });
|
|
108
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
109
|
+
|
|
110
|
+
expect(proxy.isLoaded).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns true when wrapped classifier isLoaded is true', () => {
|
|
114
|
+
const wrapped = makeWrapped({ isLoaded: true });
|
|
115
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
116
|
+
|
|
117
|
+
expect(proxy.isLoaded).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('tracks changes to wrapped classifier isLoaded dynamically', () => {
|
|
121
|
+
const wrapped = makeWrapped({ isLoaded: false });
|
|
122
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
123
|
+
|
|
124
|
+
expect(proxy.isLoaded).toBe(false);
|
|
125
|
+
|
|
126
|
+
// Simulate the model loading.
|
|
127
|
+
wrapped.isLoaded = true;
|
|
128
|
+
|
|
129
|
+
expect(proxy.isLoaded).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
// 3. Fallback when Worker is undefined (Node.js / server environment)
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe('fallback when Worker is undefined', () => {
|
|
138
|
+
let originalWorker: unknown;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
// Save and remove the global Worker constructor to simulate Node.js.
|
|
142
|
+
originalWorker = (globalThis as Record<string, unknown>)['Worker'];
|
|
143
|
+
delete (globalThis as Record<string, unknown>)['Worker'];
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
// Restore the Worker constructor after each test.
|
|
148
|
+
if (originalWorker !== undefined) {
|
|
149
|
+
(globalThis as Record<string, unknown>)['Worker'] = originalWorker;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('calls wrapped.classify() directly when Worker is undefined', async () => {
|
|
154
|
+
const wrapped = makeWrapped();
|
|
155
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
156
|
+
|
|
157
|
+
await proxy.classify('some text');
|
|
158
|
+
|
|
159
|
+
// The wrapped classifier must have been called on the main thread.
|
|
160
|
+
expect(wrapped.classify).toHaveBeenCalledOnce();
|
|
161
|
+
expect(wrapped.classify).toHaveBeenCalledWith('some text');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns the wrapped classifier result when Worker is undefined', async () => {
|
|
165
|
+
const wrapped = makeWrapped();
|
|
166
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
167
|
+
|
|
168
|
+
const result = await proxy.classify('test input');
|
|
169
|
+
|
|
170
|
+
expect(result).toEqual(MOCK_RESULT);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
// 4. useWebWorker: false forces main-thread delegation
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe('useWebWorker: false option', () => {
|
|
179
|
+
it('delegates directly to wrapped.classify() when useWebWorker is false', async () => {
|
|
180
|
+
const wrapped = makeWrapped();
|
|
181
|
+
const config: BrowserConfig = { useWebWorker: false };
|
|
182
|
+
const proxy = new WorkerClassifierProxy(wrapped, config);
|
|
183
|
+
|
|
184
|
+
await proxy.classify('hello world');
|
|
185
|
+
|
|
186
|
+
expect(wrapped.classify).toHaveBeenCalledOnce();
|
|
187
|
+
expect(wrapped.classify).toHaveBeenCalledWith('hello world');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns the wrapped result when useWebWorker is false', async () => {
|
|
191
|
+
const wrapped = makeWrapped();
|
|
192
|
+
const proxy = new WorkerClassifierProxy(wrapped, { useWebWorker: false });
|
|
193
|
+
|
|
194
|
+
const result = await proxy.classify('hello world');
|
|
195
|
+
|
|
196
|
+
expect(result).toEqual(MOCK_RESULT);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('does NOT use the Worker even when Worker is globally available', async () => {
|
|
200
|
+
// Stub a Worker constructor to detect attempted usage.
|
|
201
|
+
const workerSpy = vi.fn(() => {
|
|
202
|
+
throw new Error('Worker should not have been created');
|
|
203
|
+
});
|
|
204
|
+
(globalThis as Record<string, unknown>)['Worker'] = workerSpy;
|
|
205
|
+
|
|
206
|
+
const wrapped = makeWrapped();
|
|
207
|
+
const proxy = new WorkerClassifierProxy(wrapped, { useWebWorker: false });
|
|
208
|
+
|
|
209
|
+
// Should not throw — Worker constructor must not be called.
|
|
210
|
+
await expect(proxy.classify('text')).resolves.toEqual(MOCK_RESULT);
|
|
211
|
+
expect(workerSpy).not.toHaveBeenCalled();
|
|
212
|
+
|
|
213
|
+
// Clean up the stub.
|
|
214
|
+
delete (globalThis as Record<string, unknown>)['Worker'];
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// -------------------------------------------------------------------------
|
|
219
|
+
// 5. Worker creation failure — sets workerFailed and falls back
|
|
220
|
+
// -------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe('Worker creation failure fallback', () => {
|
|
223
|
+
let originalWorker: unknown;
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
originalWorker = (globalThis as Record<string, unknown>)['Worker'];
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
afterEach(() => {
|
|
230
|
+
if (originalWorker !== undefined) {
|
|
231
|
+
(globalThis as Record<string, unknown>)['Worker'] = originalWorker;
|
|
232
|
+
} else {
|
|
233
|
+
delete (globalThis as Record<string, unknown>)['Worker'];
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('falls back to main-thread when Worker constructor throws', async () => {
|
|
238
|
+
// Stub a Worker that always throws on construction (CSP violation, etc.).
|
|
239
|
+
(globalThis as Record<string, unknown>)['Worker'] = vi.fn(() => {
|
|
240
|
+
throw new Error('Blocked by CSP');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const wrapped = makeWrapped();
|
|
244
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
245
|
+
|
|
246
|
+
// The proxy should catch the Worker error and fall back gracefully.
|
|
247
|
+
const result = await proxy.classify('text that hits CSP');
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual(MOCK_RESULT);
|
|
250
|
+
expect(wrapped.classify).toHaveBeenCalledOnce();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('uses main-thread for all subsequent calls after Worker creation failure', async () => {
|
|
254
|
+
// First Worker call throws; subsequent calls should not attempt Worker creation.
|
|
255
|
+
const workerCallCount = { value: 0 };
|
|
256
|
+
(globalThis as Record<string, unknown>)['Worker'] = vi.fn(() => {
|
|
257
|
+
workerCallCount.value++;
|
|
258
|
+
throw new Error('Worker unavailable');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const wrapped = makeWrapped();
|
|
262
|
+
const proxy = new WorkerClassifierProxy(wrapped);
|
|
263
|
+
|
|
264
|
+
// First call — Worker creation fails, falls back.
|
|
265
|
+
await proxy.classify('call 1');
|
|
266
|
+
// Second call — should not attempt to create another Worker.
|
|
267
|
+
await proxy.classify('call 2');
|
|
268
|
+
// Third call — same expectation.
|
|
269
|
+
await proxy.classify('call 3');
|
|
270
|
+
|
|
271
|
+
// Worker constructor should only have been called once (on the first classify).
|
|
272
|
+
expect(workerCallCount.value).toBe(1);
|
|
273
|
+
|
|
274
|
+
// The wrapped classifier should have been called for all three.
|
|
275
|
+
expect(wrapped.classify).toHaveBeenCalledTimes(3);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
// 6. dispose() delegation
|
|
281
|
+
// -------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
describe('dispose()', () => {
|
|
284
|
+
it('calls wrapped.dispose() when dispose is available', async () => {
|
|
285
|
+
const wrapped = makeWrapped();
|
|
286
|
+
const proxy = new WorkerClassifierProxy(wrapped, { useWebWorker: false });
|
|
287
|
+
|
|
288
|
+
await proxy.dispose();
|
|
289
|
+
|
|
290
|
+
expect(wrapped.dispose).toHaveBeenCalledOnce();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('does not throw when wrapped classifier has no dispose()', async () => {
|
|
294
|
+
// Remove dispose from the wrapped classifier to test optional handling.
|
|
295
|
+
const wrapped = makeWrapped();
|
|
296
|
+
delete (wrapped as Partial<IContentClassifier>).dispose;
|
|
297
|
+
|
|
298
|
+
const proxy = new WorkerClassifierProxy(wrapped, { useWebWorker: false });
|
|
299
|
+
|
|
300
|
+
await expect(proxy.dispose()).resolves.toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|