content-moderation-sdk 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +275 -0
- package/content-moderation-sdk.jpg +0 -0
- package/dist/azure.d.ts +38 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +114 -0
- package/dist/azure.js.map +1 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +313 -0
- package/dist/core.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +77 -0
- package/dist/errors.js.map +1 -0
- package/dist/http.d.ts +9 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +87 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/mistral.d.ts +23 -0
- package/dist/mistral.d.ts.map +1 -0
- package/dist/mistral.js +81 -0
- package/dist/mistral.js.map +1 -0
- package/dist/openai.d.ts +28 -0
- package/dist/openai.d.ts.map +1 -0
- package/dist/openai.js +105 -0
- package/dist/openai.js.map +1 -0
- package/dist/testing.d.ts +18 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +44 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +229 -0
- package/dist/utils.js.map +1 -0
- package/package.json +76 -1
- package/src/azure.ts +191 -0
- package/src/core.ts +440 -0
- package/src/errors.ts +109 -0
- package/src/http.ts +94 -0
- package/src/index.ts +43 -0
- package/src/mistral.ts +124 -0
- package/src/openai.ts +162 -0
- package/src/testing.ts +85 -0
- package/src/types.ts +241 -0
- package/src/utils.ts +281 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ModerationAbortError,
|
|
3
|
+
ModerationAdapterError,
|
|
4
|
+
ModerationAdapterNotFoundError,
|
|
5
|
+
ModerationRouteError,
|
|
6
|
+
ModerationValidationError,
|
|
7
|
+
} from "./errors.js";
|
|
8
|
+
import type {
|
|
9
|
+
BoundModerationClient,
|
|
10
|
+
ModerationAdapter,
|
|
11
|
+
ModerationAdapterContext,
|
|
12
|
+
ModerationClient,
|
|
13
|
+
ModerationClientOptions,
|
|
14
|
+
ModerationFallbackConfig,
|
|
15
|
+
ModerationHookEvent,
|
|
16
|
+
ModerationHooks,
|
|
17
|
+
ModerationInput,
|
|
18
|
+
ModerationItem,
|
|
19
|
+
ModerationOptions,
|
|
20
|
+
ModerationProcessingMode,
|
|
21
|
+
ModerationResult,
|
|
22
|
+
ModerationRetryConfig,
|
|
23
|
+
ModerationSettledResult,
|
|
24
|
+
ModerationValidationResult,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
import {
|
|
27
|
+
assertModerationInput,
|
|
28
|
+
flattenConversation,
|
|
29
|
+
isAbortLike,
|
|
30
|
+
normalizeAdapterResult,
|
|
31
|
+
normalizeSdkError,
|
|
32
|
+
raceWithAbort,
|
|
33
|
+
sleepWithAbort,
|
|
34
|
+
throwIfAborted,
|
|
35
|
+
toAdapterError,
|
|
36
|
+
} from "./utils.js";
|
|
37
|
+
|
|
38
|
+
const defaultDelay = (attempt: number) => Math.min(100 * 2 ** (attempt - 1), 2_000);
|
|
39
|
+
|
|
40
|
+
type AnyAdapter = ModerationAdapter<string, unknown, unknown>;
|
|
41
|
+
|
|
42
|
+
type PreparedRoute = {
|
|
43
|
+
adapter: AnyAdapter;
|
|
44
|
+
input: ModerationInput;
|
|
45
|
+
mode: ModerationProcessingMode;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type ResolvedRoute = {
|
|
49
|
+
primary: string;
|
|
50
|
+
names: readonly string[];
|
|
51
|
+
fallback?: ModerationFallbackConfig<string>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function createModerationClient<const TAdapters extends readonly ModerationAdapter[]>(
|
|
55
|
+
options: ModerationClientOptions<TAdapters>,
|
|
56
|
+
): ModerationClient<TAdapters> {
|
|
57
|
+
if (options.adapters.length === 0) {
|
|
58
|
+
throw new ModerationValidationError("createModerationClient requires at least one adapter.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const adapters = new Map<string, AnyAdapter>();
|
|
62
|
+
for (const adapter of options.adapters) {
|
|
63
|
+
if (!adapter.name || typeof adapter.name !== "string") {
|
|
64
|
+
throw new ModerationValidationError("Every moderation adapter requires a name.");
|
|
65
|
+
}
|
|
66
|
+
if (adapters.has(adapter.name)) {
|
|
67
|
+
throw new ModerationValidationError(`Duplicate moderation adapter "${adapter.name}".`);
|
|
68
|
+
}
|
|
69
|
+
adapters.set(adapter.name, adapter);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const defaultAdapter = options.defaultAdapter ?? options.adapters[0]?.name;
|
|
73
|
+
if (!defaultAdapter) {
|
|
74
|
+
throw new ModerationValidationError("createModerationClient requires a default adapter.");
|
|
75
|
+
}
|
|
76
|
+
requireAdapter(adapters, defaultAdapter);
|
|
77
|
+
validateRetry(options.retry);
|
|
78
|
+
validateFallback(adapters, options.fallback);
|
|
79
|
+
|
|
80
|
+
const validate = async (
|
|
81
|
+
input: ModerationInput,
|
|
82
|
+
requestOptions?: ModerationOptions<string>,
|
|
83
|
+
): Promise<ModerationValidationResult> => {
|
|
84
|
+
throwIfAborted(requestOptions?.signal);
|
|
85
|
+
validateRetry(requestOptions?.retry ?? options.retry);
|
|
86
|
+
const route = resolveRoute(defaultAdapter, options.fallback, requestOptions);
|
|
87
|
+
validateFallback(adapters, route.fallback);
|
|
88
|
+
const prepared = await prepareRoutes(adapters, route.names, input);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
adapter: route.primary,
|
|
92
|
+
routes: route.names.map((name) => ({
|
|
93
|
+
adapter: name,
|
|
94
|
+
mode: prepared.get(name)?.mode ?? "native",
|
|
95
|
+
})),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const moderate = async (
|
|
100
|
+
input: ModerationInput,
|
|
101
|
+
requestOptions?: ModerationOptions<string>,
|
|
102
|
+
): Promise<ModerationResult> => {
|
|
103
|
+
throwIfAborted(requestOptions?.signal);
|
|
104
|
+
validateRetry(requestOptions?.retry ?? options.retry);
|
|
105
|
+
const route = resolveRoute(defaultAdapter, options.fallback, requestOptions);
|
|
106
|
+
validateFallback(adapters, route.fallback);
|
|
107
|
+
const prepared = await prepareRoutes(adapters, route.names, input);
|
|
108
|
+
const failures: ModerationAdapterError[] = [];
|
|
109
|
+
|
|
110
|
+
for (const [index, name] of route.names.entries()) {
|
|
111
|
+
const candidate = prepared.get(name);
|
|
112
|
+
if (!candidate) {
|
|
113
|
+
throw new ModerationValidationError(`Moderation route "${name}" was not prepared.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
return await attemptAdapter({
|
|
118
|
+
candidate,
|
|
119
|
+
originalInput: input,
|
|
120
|
+
requestOptions,
|
|
121
|
+
retry: requestOptions?.retry ?? options.retry,
|
|
122
|
+
hooks: options.hooks,
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof ModerationAbortError || error instanceof ModerationValidationError) {
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const failure = unwrapAttemptFailure(name, error);
|
|
130
|
+
failures.push(failure.error);
|
|
131
|
+
await invokeHook(options.hooks?.onError, {
|
|
132
|
+
adapter: name,
|
|
133
|
+
input,
|
|
134
|
+
inputType: input.type,
|
|
135
|
+
mode: candidate.mode,
|
|
136
|
+
attempt: failure.attempt,
|
|
137
|
+
metadata: requestOptions?.metadata,
|
|
138
|
+
error: failure.error,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const nextAdapter = route.names[index + 1];
|
|
142
|
+
if (!nextAdapter) break;
|
|
143
|
+
|
|
144
|
+
const shouldFallback = route.fallback?.shouldFallback;
|
|
145
|
+
if (shouldFallback) {
|
|
146
|
+
let shouldContinue: boolean;
|
|
147
|
+
try {
|
|
148
|
+
shouldContinue = await shouldFallback(failure.error, nextAdapter);
|
|
149
|
+
} catch (predicateError) {
|
|
150
|
+
throw new ModerationValidationError(
|
|
151
|
+
"The moderation fallback predicate threw an error.",
|
|
152
|
+
predicateError,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!shouldContinue) break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new ModerationRouteError(failures);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const moderateMany = async (
|
|
164
|
+
items: readonly ModerationItem<string>[],
|
|
165
|
+
requestOptions?: ModerationOptions<string>,
|
|
166
|
+
): Promise<readonly ModerationSettledResult[]> => {
|
|
167
|
+
const results: ModerationSettledResult[] = [];
|
|
168
|
+
|
|
169
|
+
for (const [index, item] of items.entries()) {
|
|
170
|
+
try {
|
|
171
|
+
const result = await moderate(item.input, mergeOptions(requestOptions, item.options));
|
|
172
|
+
results.push({ ok: true, index, result });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
results.push({ ok: false, index, error: normalizeSdkError(error) });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return results;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const client = {
|
|
182
|
+
adapters,
|
|
183
|
+
defaultAdapter,
|
|
184
|
+
validate,
|
|
185
|
+
moderate,
|
|
186
|
+
moderateMany,
|
|
187
|
+
adapter(name: string) {
|
|
188
|
+
return requireAdapter(adapters, name);
|
|
189
|
+
},
|
|
190
|
+
withAdapter(name: string) {
|
|
191
|
+
requireAdapter(adapters, name);
|
|
192
|
+
return {
|
|
193
|
+
validate(input: ModerationInput, boundOptions?: ModerationOptions<string>) {
|
|
194
|
+
return validate(input, { ...boundOptions, adapter: name });
|
|
195
|
+
},
|
|
196
|
+
moderate(input: ModerationInput, boundOptions?: ModerationOptions<string>) {
|
|
197
|
+
return moderate(input, { ...boundOptions, adapter: name });
|
|
198
|
+
},
|
|
199
|
+
moderateMany(
|
|
200
|
+
items: readonly ModerationItem<string>[],
|
|
201
|
+
boundOptions?: ModerationOptions<string>,
|
|
202
|
+
) {
|
|
203
|
+
return moderateMany(items, { ...boundOptions, adapter: name });
|
|
204
|
+
},
|
|
205
|
+
} as BoundModerationClient<AnyAdapter>;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return client as unknown as ModerationClient<TAdapters>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function prepareRoutes(
|
|
213
|
+
adapters: ReadonlyMap<string, AnyAdapter>,
|
|
214
|
+
names: readonly string[],
|
|
215
|
+
input: ModerationInput,
|
|
216
|
+
): Promise<ReadonlyMap<string, PreparedRoute>> {
|
|
217
|
+
assertModerationInput(input);
|
|
218
|
+
const prepared = new Map<string, PreparedRoute>();
|
|
219
|
+
|
|
220
|
+
for (const name of names) {
|
|
221
|
+
const adapter = requireAdapter(adapters, name);
|
|
222
|
+
const candidate = prepareInput(adapter, input);
|
|
223
|
+
try {
|
|
224
|
+
await adapter.validate?.(candidate.input, {
|
|
225
|
+
adapter: name,
|
|
226
|
+
inputType: input.type,
|
|
227
|
+
mode: candidate.mode,
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof ModerationValidationError) throw error;
|
|
231
|
+
throw new ModerationValidationError(`Moderation adapter "${name}" validation failed.`, {
|
|
232
|
+
adapter: name,
|
|
233
|
+
cause: error,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
prepared.set(name, { adapter, ...candidate });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return prepared;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function prepareInput(
|
|
243
|
+
adapter: AnyAdapter,
|
|
244
|
+
input: ModerationInput,
|
|
245
|
+
): Pick<PreparedRoute, "input" | "mode"> {
|
|
246
|
+
if (input.type === "text") {
|
|
247
|
+
if (!adapter.capabilities.text) unsupportedInput(adapter.name, "text");
|
|
248
|
+
return { input, mode: "native" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (input.type === "image") {
|
|
252
|
+
if (!adapter.capabilities.image) unsupportedInput(adapter.name, "image");
|
|
253
|
+
return { input, mode: "native" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (adapter.capabilities.conversation === "unsupported") {
|
|
257
|
+
unsupportedInput(adapter.name, "conversation");
|
|
258
|
+
}
|
|
259
|
+
if (adapter.capabilities.conversation === "flattened") {
|
|
260
|
+
return {
|
|
261
|
+
input: { type: "text", text: flattenConversation(input) },
|
|
262
|
+
mode: "flattened",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { input, mode: "native" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function unsupportedInput(adapter: string, inputType: ModerationInput["type"]): never {
|
|
269
|
+
throw new ModerationValidationError(
|
|
270
|
+
`Moderation adapter "${adapter}" does not support ${inputType} input.`,
|
|
271
|
+
{ adapter, inputType },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function attemptAdapter(input: {
|
|
276
|
+
candidate: PreparedRoute;
|
|
277
|
+
originalInput: ModerationInput;
|
|
278
|
+
requestOptions?: ModerationOptions<string>;
|
|
279
|
+
retry?: ModerationRetryConfig;
|
|
280
|
+
hooks?: ModerationHooks;
|
|
281
|
+
}): Promise<ModerationResult> {
|
|
282
|
+
const maxAttempts = input.retry?.maxAttempts ?? 1;
|
|
283
|
+
const shouldRetry =
|
|
284
|
+
input.retry?.shouldRetry ?? ((error: ModerationAdapterError) => error.retryable);
|
|
285
|
+
const delayFor = input.retry?.delay ?? defaultDelay;
|
|
286
|
+
const name = input.candidate.adapter.name;
|
|
287
|
+
|
|
288
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
289
|
+
const hookEvent: ModerationHookEvent = {
|
|
290
|
+
adapter: name,
|
|
291
|
+
input: input.originalInput,
|
|
292
|
+
inputType: input.originalInput.type,
|
|
293
|
+
mode: input.candidate.mode,
|
|
294
|
+
attempt,
|
|
295
|
+
metadata: input.requestOptions?.metadata,
|
|
296
|
+
};
|
|
297
|
+
await invokeHook(input.hooks?.beforeModerate, hookEvent);
|
|
298
|
+
throwIfAborted(input.requestOptions?.signal);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const context: ModerationAdapterContext = {
|
|
302
|
+
adapter: name,
|
|
303
|
+
inputType: input.originalInput.type,
|
|
304
|
+
mode: input.candidate.mode,
|
|
305
|
+
attempt,
|
|
306
|
+
signal: input.requestOptions?.signal,
|
|
307
|
+
metadata: input.requestOptions?.metadata,
|
|
308
|
+
};
|
|
309
|
+
const operation = Promise.resolve(
|
|
310
|
+
input.candidate.adapter.moderate(input.candidate.input, context),
|
|
311
|
+
);
|
|
312
|
+
const adapterResult = await raceWithAbort(operation, input.requestOptions?.signal);
|
|
313
|
+
const normalized = normalizeAdapterResult(name, adapterResult);
|
|
314
|
+
const result: ModerationResult = {
|
|
315
|
+
...normalized,
|
|
316
|
+
inputType: input.originalInput.type,
|
|
317
|
+
mode: input.candidate.mode,
|
|
318
|
+
};
|
|
319
|
+
await invokeHook(input.hooks?.afterModerate, { ...hookEvent, result });
|
|
320
|
+
return result;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (isAbortLike(error)) {
|
|
323
|
+
throw error instanceof ModerationAbortError
|
|
324
|
+
? error
|
|
325
|
+
: new ModerationAbortError(input.requestOptions?.signal?.reason ?? error);
|
|
326
|
+
}
|
|
327
|
+
if (error instanceof ModerationValidationError) throw error;
|
|
328
|
+
|
|
329
|
+
const normalized = toAdapterError(name, error);
|
|
330
|
+
if (attempt >= maxAttempts) {
|
|
331
|
+
throw new AdapterAttemptFailure(normalized, attempt);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let retry: boolean;
|
|
335
|
+
try {
|
|
336
|
+
retry = shouldRetry(normalized, attempt);
|
|
337
|
+
} catch (callbackError) {
|
|
338
|
+
throw new ModerationValidationError(
|
|
339
|
+
"The moderation retry shouldRetry callback threw an error.",
|
|
340
|
+
callbackError,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (!retry) throw new AdapterAttemptFailure(normalized, attempt);
|
|
344
|
+
|
|
345
|
+
let delayMs: number;
|
|
346
|
+
try {
|
|
347
|
+
delayMs = delayFor(attempt, normalized);
|
|
348
|
+
} catch (callbackError) {
|
|
349
|
+
throw new ModerationValidationError(
|
|
350
|
+
"The moderation retry delay callback threw an error.",
|
|
351
|
+
callbackError,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
355
|
+
throw new ModerationValidationError(
|
|
356
|
+
"Moderation retry delay must be a non-negative number.",
|
|
357
|
+
{
|
|
358
|
+
attempt,
|
|
359
|
+
delayMs,
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
await invokeHook(input.hooks?.onRetry, {
|
|
364
|
+
...hookEvent,
|
|
365
|
+
error: normalized,
|
|
366
|
+
nextAttempt: attempt + 1,
|
|
367
|
+
delayMs,
|
|
368
|
+
});
|
|
369
|
+
await sleepWithAbort(delayMs, input.requestOptions?.signal);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
throw new ModerationValidationError("Moderation retry loop exited unexpectedly.");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
class AdapterAttemptFailure {
|
|
377
|
+
constructor(
|
|
378
|
+
readonly error: ModerationAdapterError,
|
|
379
|
+
readonly attempt: number,
|
|
380
|
+
) {}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function unwrapAttemptFailure(adapter: string, error: unknown): AdapterAttemptFailure {
|
|
384
|
+
if (error instanceof AdapterAttemptFailure) return error;
|
|
385
|
+
return new AdapterAttemptFailure(toAdapterError(adapter, error), 1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function resolveRoute(
|
|
389
|
+
defaultAdapter: string,
|
|
390
|
+
clientFallback: ModerationFallbackConfig<string> | undefined,
|
|
391
|
+
requestOptions?: ModerationOptions<string>,
|
|
392
|
+
): ResolvedRoute {
|
|
393
|
+
const primary = requestOptions?.adapter ?? defaultAdapter;
|
|
394
|
+
const fallback = requestOptions?.fallback ?? clientFallback;
|
|
395
|
+
const names = [primary, ...(fallback?.adapters ?? [])].filter(
|
|
396
|
+
(name, index, route) => route.indexOf(name) === index,
|
|
397
|
+
);
|
|
398
|
+
return { primary, names, fallback };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function validateFallback(
|
|
402
|
+
adapters: ReadonlyMap<string, AnyAdapter>,
|
|
403
|
+
fallback?: ModerationFallbackConfig<string>,
|
|
404
|
+
): void {
|
|
405
|
+
for (const name of fallback?.adapters ?? []) requireAdapter(adapters, name);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateRetry(retry?: ModerationRetryConfig): void {
|
|
409
|
+
if (retry?.maxAttempts === undefined) return;
|
|
410
|
+
if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1) {
|
|
411
|
+
throw new ModerationValidationError(
|
|
412
|
+
"Moderation retry maxAttempts must be an integer of at least 1.",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function requireAdapter(adapters: ReadonlyMap<string, AnyAdapter>, name: string): AnyAdapter {
|
|
418
|
+
const adapter = adapters.get(name);
|
|
419
|
+
if (!adapter) throw new ModerationAdapterNotFoundError(name);
|
|
420
|
+
return adapter;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function mergeOptions(
|
|
424
|
+
base?: ModerationOptions<string>,
|
|
425
|
+
item?: ModerationOptions<string>,
|
|
426
|
+
): ModerationOptions<string> | undefined {
|
|
427
|
+
if (!base && !item) return undefined;
|
|
428
|
+
return { ...base, ...item };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function invokeHook<T>(
|
|
432
|
+
hook: ((event: T) => unknown | Promise<unknown>) | undefined,
|
|
433
|
+
event: T,
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
try {
|
|
436
|
+
await hook?.(event);
|
|
437
|
+
} catch {
|
|
438
|
+
// Hooks are best-effort observers and must never alter moderation behavior.
|
|
439
|
+
}
|
|
440
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type ModerationErrorCode =
|
|
2
|
+
| "validation_error"
|
|
3
|
+
| "adapter_not_found"
|
|
4
|
+
| "adapter_error"
|
|
5
|
+
| "route_error"
|
|
6
|
+
| "aborted"
|
|
7
|
+
| "internal_error";
|
|
8
|
+
|
|
9
|
+
export type ModerationSdkErrorOptions = {
|
|
10
|
+
code: ModerationErrorCode | (string & {});
|
|
11
|
+
adapter?: string;
|
|
12
|
+
status?: number;
|
|
13
|
+
retryable?: boolean;
|
|
14
|
+
details?: unknown;
|
|
15
|
+
cause?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class ModerationSdkError extends Error {
|
|
19
|
+
readonly code: ModerationSdkErrorOptions["code"];
|
|
20
|
+
readonly adapter?: string;
|
|
21
|
+
readonly status?: number;
|
|
22
|
+
readonly retryable: boolean;
|
|
23
|
+
readonly details?: unknown;
|
|
24
|
+
|
|
25
|
+
constructor(message: string, options: ModerationSdkErrorOptions) {
|
|
26
|
+
super(message, { cause: options.cause });
|
|
27
|
+
this.name = "ModerationSdkError";
|
|
28
|
+
this.code = options.code;
|
|
29
|
+
this.adapter = options.adapter;
|
|
30
|
+
this.status = options.status;
|
|
31
|
+
this.retryable = options.retryable ?? false;
|
|
32
|
+
this.details = options.details;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toJSON(): Readonly<Record<string, unknown>> {
|
|
36
|
+
return Object.fromEntries([
|
|
37
|
+
...Object.entries(this),
|
|
38
|
+
["name", this.name],
|
|
39
|
+
["message", this.message],
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ModerationValidationError extends ModerationSdkError {
|
|
45
|
+
constructor(message: string, details?: unknown) {
|
|
46
|
+
super(message, { code: "validation_error", details });
|
|
47
|
+
this.name = "ModerationValidationError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ModerationAdapterNotFoundError extends ModerationSdkError {
|
|
52
|
+
constructor(adapter: string) {
|
|
53
|
+
super(`Moderation adapter "${adapter}" is not registered.`, {
|
|
54
|
+
code: "adapter_not_found",
|
|
55
|
+
adapter,
|
|
56
|
+
});
|
|
57
|
+
this.name = "ModerationAdapterNotFoundError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ModerationAdapterErrorOptions = Omit<ModerationSdkErrorOptions, "code" | "adapter"> & {
|
|
62
|
+
adapter: string;
|
|
63
|
+
code?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export class ModerationAdapterError extends ModerationSdkError {
|
|
67
|
+
declare readonly adapter: string;
|
|
68
|
+
|
|
69
|
+
constructor(message: string, options: ModerationAdapterErrorOptions) {
|
|
70
|
+
super(message, {
|
|
71
|
+
code: options.code ?? "adapter_error",
|
|
72
|
+
adapter: options.adapter,
|
|
73
|
+
status: options.status,
|
|
74
|
+
retryable: options.retryable,
|
|
75
|
+
details: options.details,
|
|
76
|
+
cause: options.cause,
|
|
77
|
+
});
|
|
78
|
+
this.name = "ModerationAdapterError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ModerationRouteError extends ModerationSdkError {
|
|
83
|
+
readonly failures: readonly ModerationAdapterError[];
|
|
84
|
+
|
|
85
|
+
constructor(failures: readonly ModerationAdapterError[]) {
|
|
86
|
+
super("Every moderation adapter in the route failed.", {
|
|
87
|
+
code: "route_error",
|
|
88
|
+
retryable: failures.some((failure) => failure.retryable),
|
|
89
|
+
details: failures,
|
|
90
|
+
cause: failures.at(-1),
|
|
91
|
+
});
|
|
92
|
+
this.name = "ModerationRouteError";
|
|
93
|
+
this.failures = failures;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class ModerationAbortError extends ModerationSdkError {
|
|
98
|
+
constructor(reason?: unknown) {
|
|
99
|
+
super("Moderation was aborted.", {
|
|
100
|
+
code: "aborted",
|
|
101
|
+
cause: reason,
|
|
102
|
+
});
|
|
103
|
+
this.name = "ModerationAbortError";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isRetryableModerationError(error: unknown): error is ModerationSdkError {
|
|
108
|
+
return error instanceof ModerationSdkError && error.retryable;
|
|
109
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ModerationAbortError, ModerationAdapterError } from "./errors.js";
|
|
2
|
+
import { isAbortLike } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
export function isRetryableHttpStatus(status: number): boolean {
|
|
5
|
+
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function requestJson<T>(input: {
|
|
9
|
+
adapter: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
fetcher: typeof fetch;
|
|
12
|
+
url: string;
|
|
13
|
+
init: RequestInit;
|
|
14
|
+
}): Promise<T> {
|
|
15
|
+
let response: Response;
|
|
16
|
+
try {
|
|
17
|
+
response = await input.fetcher(input.url, input.init);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (input.init.signal?.aborted || isAbortLike(error)) {
|
|
20
|
+
throw new ModerationAbortError(input.init.signal?.reason ?? error);
|
|
21
|
+
}
|
|
22
|
+
throw new ModerationAdapterError(`${input.provider} request failed.`, {
|
|
23
|
+
adapter: input.adapter,
|
|
24
|
+
code: "network_error",
|
|
25
|
+
retryable: true,
|
|
26
|
+
cause: error,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let body: unknown;
|
|
31
|
+
try {
|
|
32
|
+
body = await readBody(response);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (input.init.signal?.aborted || isAbortLike(error)) {
|
|
35
|
+
throw new ModerationAbortError(input.init.signal?.reason ?? error);
|
|
36
|
+
}
|
|
37
|
+
throw new ModerationAdapterError(`${input.provider} response could not be read.`, {
|
|
38
|
+
adapter: input.adapter,
|
|
39
|
+
code: "network_error",
|
|
40
|
+
status: response.status,
|
|
41
|
+
retryable: true,
|
|
42
|
+
cause: error,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new ModerationAdapterError(httpErrorMessage(input.provider, response.status, body), {
|
|
47
|
+
adapter: input.adapter,
|
|
48
|
+
code: "http_error",
|
|
49
|
+
status: response.status,
|
|
50
|
+
retryable: isRetryableHttpStatus(response.status),
|
|
51
|
+
details: body,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (body === undefined || typeof body === "string") {
|
|
56
|
+
throw new ModerationAdapterError(`${input.provider} returned an invalid JSON response.`, {
|
|
57
|
+
adapter: input.adapter,
|
|
58
|
+
code: "invalid_response",
|
|
59
|
+
status: response.status,
|
|
60
|
+
details: body,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return body as T;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readBody(response: Response): Promise<unknown> {
|
|
68
|
+
const text = await response.text();
|
|
69
|
+
if (!text) return undefined;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(text) as unknown;
|
|
72
|
+
} catch {
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function httpErrorMessage(provider: string, status: number, body: unknown): string {
|
|
78
|
+
const detail = errorMessage(body);
|
|
79
|
+
return detail
|
|
80
|
+
? `${provider} request failed (${status}): ${detail}`
|
|
81
|
+
: `${provider} request failed (${status}).`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorMessage(body: unknown): string | undefined {
|
|
85
|
+
if (!body || typeof body !== "object")
|
|
86
|
+
return typeof body === "string" ? body.slice(0, 300) : undefined;
|
|
87
|
+
const record = body as Record<string, unknown>;
|
|
88
|
+
if (typeof record.message === "string") return record.message.slice(0, 300);
|
|
89
|
+
if (record.error && typeof record.error === "object") {
|
|
90
|
+
const nested = record.error as Record<string, unknown>;
|
|
91
|
+
if (typeof nested.message === "string") return nested.message.slice(0, 300);
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export { createModerationClient } from "./core.js";
|
|
2
|
+
export {
|
|
3
|
+
ModerationAbortError,
|
|
4
|
+
ModerationAdapterError,
|
|
5
|
+
ModerationAdapterNotFoundError,
|
|
6
|
+
ModerationRouteError,
|
|
7
|
+
ModerationSdkError,
|
|
8
|
+
ModerationValidationError,
|
|
9
|
+
isRetryableModerationError,
|
|
10
|
+
} from "./errors.js";
|
|
11
|
+
export type {
|
|
12
|
+
BoundModerationClient,
|
|
13
|
+
MaybePromise,
|
|
14
|
+
ModerationAdapter,
|
|
15
|
+
ModerationAdapterCapabilities,
|
|
16
|
+
ModerationAdapterContext,
|
|
17
|
+
ModerationAdapterResult,
|
|
18
|
+
ModerationAdapterValidationContext,
|
|
19
|
+
ModerationAfterEvent,
|
|
20
|
+
ModerationCategoryAssessment,
|
|
21
|
+
ModerationClient,
|
|
22
|
+
ModerationClientOptions,
|
|
23
|
+
ModerationConversationInput,
|
|
24
|
+
ModerationErrorEvent,
|
|
25
|
+
ModerationFallbackConfig,
|
|
26
|
+
ModerationHookEvent,
|
|
27
|
+
ModerationHooks,
|
|
28
|
+
ModerationImageInput,
|
|
29
|
+
ModerationImageSource,
|
|
30
|
+
ModerationInput,
|
|
31
|
+
ModerationInputType,
|
|
32
|
+
ModerationItem,
|
|
33
|
+
ModerationMessage,
|
|
34
|
+
ModerationMessageRole,
|
|
35
|
+
ModerationOptions,
|
|
36
|
+
ModerationProcessingMode,
|
|
37
|
+
ModerationResult,
|
|
38
|
+
ModerationRetryConfig,
|
|
39
|
+
ModerationSettledResult,
|
|
40
|
+
ModerationTextInput,
|
|
41
|
+
ModerationValidationResult,
|
|
42
|
+
ModerationValidationRoute,
|
|
43
|
+
} from "./types.js";
|