Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

995 lines
31 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache, getCache, getScopedCacheKey, isCacheEnabled } from '../../../src/cache';
import {
formatModerationInput,
type ImageInput,
isImageInput,
isTextInput,
OpenAiModerationProvider,
supportsImageInput,
type TextInput,
} from '../../../src/providers/openai/moderation';
import { getOpenAiMissingApiKeyMessage } from './shared';
vi.mock('../../../src/cache');
vi.mock('../../../src/logger');
describe('OpenAiModerationProvider', () => {
// Standard setup for all tests
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(isCacheEnabled).mockImplementation(function () {
return false;
});
vi.mocked(getScopedCacheKey).mockImplementation(function (cacheKey) {
return cacheKey;
});
vi.mocked(fetchWithCache).mockImplementation(async function () {
return {
data: {},
status: 200,
statusText: 'OK',
cached: false,
};
});
});
// Helper function to create a provider instance
const createProvider = (modelName = 'text-moderation-latest') => {
return new OpenAiModerationProvider(modelName, {
config: { apiKey: 'test-key' },
});
};
describe('Basic functionality', () => {
it('should moderate content and detect harmful content', async () => {
const provider = createProvider();
const mockResponse = {
id: 'modr-123',
model: 'text-moderation-latest',
results: [
{
flagged: true,
categories: {
hate: true,
'hate/threatening': false,
},
category_scores: {
hate: 0.99,
'hate/threatening': 0.01,
},
},
],
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const result = await provider.callModerationApi('user input', 'assistant response');
expect(result).toEqual({
flags: [
{
code: 'hate',
description: 'hate',
confidence: 0.99,
},
],
});
expect(fetchWithCache).toHaveBeenCalledWith(
expect.stringContaining('/moderations'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-promptfoo-silent': 'true',
Authorization: 'Bearer test-key',
'X-OpenAI-Originator': 'promptfoo',
}),
body: expect.stringContaining('"model":"text-moderation-latest"'),
}),
expect.any(Number),
'json',
true,
undefined,
);
});
it('should return empty flags for safe content', async () => {
const provider = createProvider();
const mockResponse = {
id: 'modr-123',
model: 'text-moderation-latest',
results: [
{
flagged: false,
categories: {
hate: false,
'hate/threatening': false,
},
category_scores: {
hate: 0.01,
'hate/threatening': 0.01,
},
},
],
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const result = await provider.callModerationApi('user input', 'assistant response');
expect(result).toEqual({
flags: [],
});
});
it('should use flagged categories when scores are close together', async () => {
const provider = createProvider();
const mockResponse = {
id: 'modr-123',
model: 'text-moderation-latest',
results: [
{
flagged: true,
categories: {
hate: true,
'hate/threatening': false,
},
category_scores: {
hate: 0.5,
'hate/threatening': 0.49,
},
},
],
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const result = await provider.callModerationApi('user input', 'assistant response');
expect(result).toEqual({
flags: [
{
code: 'hate',
description: 'hate',
confidence: 0.5,
},
],
});
});
});
describe('Error handling', () => {
it('should handle API call errors', async () => {
const provider = createProvider();
vi.mocked(fetchWithCache).mockRejectedValueOnce(new Error('API Error'));
const result = await provider.callModerationApi('user input', 'assistant response');
expect(result).toEqual({
error: expect.stringContaining('API call error'),
});
});
it('should handle error responses from API', async () => {
const provider = createProvider();
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: { error: 'Invalid request' },
status: 400,
statusText: 'Bad Request',
cached: false,
});
const result = await provider.callModerationApi('user input', 'assistant response');
expect(result).toEqual({
error: expect.stringContaining('API error: 400'),
});
});
it('should check for API key availability', async () => {
// Create provider with empty API key - instead of testing the throw,
// we'll mock getApiKey to return empty and verify handleApiError is used
const provider = createProvider();
vi.spyOn(provider, 'getApiKey').mockReturnValue('');
// Mock the logger to verify error is logged
const logger = (await import('../../../src/logger')).default;
const errorSpy = vi.spyOn(logger, 'error');
const result = await provider.callModerationApi('user', 'assistant');
// Verify we got an error response with the expected message
expect(result).toHaveProperty('error');
expect(result.error).toContain(getOpenAiMissingApiKeyMessage());
expect(errorSpy).toHaveBeenCalledWith(
'OpenAI moderation API error',
expect.objectContaining({
error: expect.stringContaining(getOpenAiMissingApiKeyMessage()),
hasData: false,
}),
);
});
it('should use custom apiKeyEnvar in missing API key errors', async () => {
const provider = new OpenAiModerationProvider('text-moderation-latest', {
config: {
apiKeyEnvar: 'CUSTOM_MODERATION_API_KEY',
},
env: {
OPENAI_API_KEY: undefined,
CUSTOM_MODERATION_API_KEY: undefined,
},
});
vi.spyOn(provider, 'getApiKey').mockReturnValue('');
const result = await provider.callModerationApi('user', 'assistant');
expect(result.error).toContain(getOpenAiMissingApiKeyMessage('CUSTOM_MODERATION_API_KEY'));
});
it('should handle empty results from API', async () => {
const provider = createProvider();
const mockResponse = {
id: 'modr-123',
model: 'text-moderation-latest',
results: [], // Empty results array
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const result = await provider.callModerationApi('user', 'assistant');
expect(result).toEqual({ flags: [] });
});
it('should handle non-empty results with no flagged categories', async () => {
const provider = createProvider();
const mockResponse = {
id: 'modr-124',
model: 'text-moderation-latest',
results: [
{
flagged: false,
categories: {
hate: false,
'hate/threatening': false,
harassment: false,
'harassment/threatening': false,
illicit: false,
'illicit/violent': false,
'self-harm': false,
'self-harm/intent': false,
'self-harm/instructions': false,
sexual: false,
'sexual/minors': false,
violence: false,
'violence/graphic': false,
},
category_scores: {
hate: 0,
'hate/threatening': 0,
harassment: 0,
'harassment/threatening': 0,
illicit: 0,
'illicit/violent': 0,
'self-harm': 0,
'self-harm/intent': 0,
'self-harm/instructions': 0,
sexual: 0,
'sexual/minors': 0,
violence: 0,
'violence/graphic': 0,
},
category_applied_input_types: {},
},
],
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const result = await provider.callModerationApi('user', 'assistant');
expect(result).toEqual({ flags: [] });
});
});
describe('Caching', () => {
it('should use cache when enabled', async () => {
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const headerSecret = 'Bearer cache-secret-header';
const provider = new OpenAiModerationProvider('text-moderation-latest', {
config: { apiKey: 'test-key', headers: { Authorization: headerSecret } },
});
const mockResponse = {
flags: [
{
code: 'hate',
description: 'hate',
confidence: 0.9,
},
],
};
const mockCache = {
get: vi.fn().mockResolvedValue(JSON.stringify(mockResponse)),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return mockCache as any;
});
const result = await provider.callModerationApi('user input', 'assistant response');
expect(result).toEqual({ ...mockResponse, cached: true });
expect(result.cached).toBe(true);
const cacheKey = mockCache.get.mock.calls[0][0] as string;
expect(cacheKey).toMatch(
/^openai:moderation:text-moderation-latest:[a-f0-9]{64}:[a-f0-9]{64}:[a-f0-9]{64}$/,
);
expect(cacheKey).not.toContain('assistant response');
expect(cacheKey).not.toContain(headerSecret);
expect(cacheKey).not.toContain('test-key');
// Verify fetchWithCache wasn't called because cache was used
expect(fetchWithCache).not.toHaveBeenCalled();
});
it('should store results in cache when caching is enabled', async () => {
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = createProvider();
const mockResponse = {
id: 'modr-123',
model: 'text-moderation-latest',
results: [
{
flagged: true,
categories: { hate: true },
category_scores: { hate: 0.95 },
},
],
};
const mockCache = {
get: vi.fn().mockResolvedValue(null), // No cached response
set: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(getCache).mockImplementation(function () {
return mockCache as any;
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
await provider.callModerationApi('user', 'assistant');
// Verify we attempted to save to cache
const cacheKey = mockCache.set.mock.calls[0][0] as string;
expect(cacheKey).toMatch(
/^openai:moderation:text-moderation-latest:[a-f0-9]{64}:[a-f0-9]{64}:[a-f0-9]{64}$/,
);
expect(cacheKey).not.toContain('assistant');
expect(mockCache.set).toHaveBeenCalledWith(
cacheKey,
expect.stringContaining('{"flags":[{"code":"hate"'),
);
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.any(Number),
'json',
true,
undefined,
);
});
it('should build cache keys from normalized moderation inputs', async () => {
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = createProvider('omni-moderation-latest');
const mockCache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(getCache).mockImplementation(function () {
return mockCache as any;
});
vi.mocked(fetchWithCache).mockResolvedValue({
data: {
id: 'modr-123',
model: 'omni-moderation-latest',
results: [{ flagged: false, categories: {}, category_scores: {} }],
},
status: 200,
statusText: 'OK',
cached: false,
});
await provider.callModerationApi('user', 'same normalized text');
await provider.callModerationApi('user', [{ type: 'text', text: 'same normalized text' }]);
expect(mockCache.get.mock.calls[0][0]).toBe(mockCache.get.mock.calls[1][0]);
});
it('should isolate cache keys by resolved API key', async () => {
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const providerA = new OpenAiModerationProvider('text-moderation-latest', {
config: { apiKey: 'sk-moderation-tenant-a' },
});
const providerB = new OpenAiModerationProvider('text-moderation-latest', {
config: { apiKey: 'sk-moderation-tenant-b' },
});
const mockCache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(getCache).mockImplementation(function () {
return mockCache as any;
});
vi.mocked(fetchWithCache).mockResolvedValue({
data: {
id: 'modr-123',
model: 'text-moderation-latest',
results: [{ flagged: false, categories: {}, category_scores: {} }],
},
status: 200,
statusText: 'OK',
cached: false,
});
await providerA.callModerationApi('user', 'same sensitive text');
await providerB.callModerationApi('user', 'same sensitive text');
const [cacheKeyA, cacheKeyB] = mockCache.get.mock.calls.map(([key]) => key as string);
expect(cacheKeyA).toMatch(
/^openai:moderation:text-moderation-latest:[a-f0-9]{64}:[a-f0-9]{64}:[a-f0-9]{64}$/,
);
expect(cacheKeyB).toMatch(
/^openai:moderation:text-moderation-latest:[a-f0-9]{64}:[a-f0-9]{64}:[a-f0-9]{64}$/,
);
expect(cacheKeyA).not.toBe(cacheKeyB);
expect(cacheKeyA).not.toContain('same sensitive text');
expect(cacheKeyA).not.toContain('sk-moderation-tenant-a');
expect(cacheKeyB).not.toContain('sk-moderation-tenant-b');
});
it('should keep resolved API key cache identity stable across module reloads', async () => {
async function getCacheKeyFromFreshModule() {
vi.resetModules();
const freshCacheModule = await import('../../../src/cache');
const mockCache = {
get: vi.fn().mockResolvedValue(JSON.stringify({ flags: [] })),
set: vi.fn(),
};
vi.mocked(freshCacheModule.isCacheEnabled).mockImplementation(function () {
return true;
});
vi.mocked(freshCacheModule.getCache).mockImplementation(function () {
return mockCache as any;
});
const { OpenAiModerationProvider: FreshOpenAiModerationProvider } = await import(
'../../../src/providers/openai/moderation'
);
const provider = new FreshOpenAiModerationProvider('text-moderation-latest', {
config: { apiKey: 'sk-moderation-reload' },
});
await provider.callModerationApi('user', 'same sensitive text');
return mockCache.get.mock.calls[0][0] as string;
}
const cacheKeyA = await getCacheKeyFromFreshModule();
const cacheKeyB = await getCacheKeyFromFreshModule();
expect(cacheKeyA).toBe(cacheKeyB);
expect(cacheKeyA).toMatch(
/^openai:moderation:text-moderation-latest:[a-f0-9]{64}:[a-f0-9]{64}:[a-f0-9]{64}$/,
);
expect(cacheKeyA).not.toContain('same sensitive text');
expect(cacheKeyA).not.toContain('sk-moderation-reload');
});
it('should deduplicate concurrent moderation requests with identical cache identity', async () => {
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = createProvider();
const mockCache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
};
const mockResponse = {
id: 'modr-123',
model: 'text-moderation-latest',
results: [{ flagged: false, categories: {}, category_scores: {} }],
};
let resolveFetch: (value: any) => void;
vi.mocked(getCache).mockImplementation(function () {
return mockCache as any;
});
vi.mocked(fetchWithCache).mockReturnValueOnce(
new Promise<any>((resolve) => {
resolveFetch = resolve;
}) as ReturnType<typeof fetchWithCache>,
);
const first = provider.callModerationApi('user', 'same sensitive text');
const second = provider.callModerationApi('user', 'same sensitive text');
await vi.waitFor(() => {
expect(fetchWithCache).toHaveBeenCalledTimes(1);
});
resolveFetch!({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
await expect(Promise.all([first, second])).resolves.toEqual([{ flags: [] }, { flags: [] }]);
});
it('should not deduplicate in-flight requests across cache namespaces', async () => {
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = createProvider();
const mockCache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
};
const mockResponse = {
id: 'modr-123',
model: 'text-moderation-latest',
results: [{ flagged: false, categories: {}, category_scores: {} }],
};
const resolvers: Array<(value: any) => void> = [];
vi.mocked(getCache).mockImplementation(function () {
return mockCache as any;
});
let namespaceIndex = 0;
vi.mocked(getScopedCacheKey).mockImplementation(function (cacheKey) {
const namespace = namespaceIndex++ === 0 ? 'repeat-0' : 'repeat-1';
return `${namespace}:${cacheKey}`;
});
vi.mocked(fetchWithCache).mockImplementation(
() =>
new Promise<any>((resolve) => {
resolvers.push(resolve);
}) as ReturnType<typeof fetchWithCache>,
);
const first = provider.callModerationApi('user', 'same sensitive text');
const second = provider.callModerationApi('user', 'same sensitive text');
await vi.waitFor(() => {
expect(fetchWithCache).toHaveBeenCalledTimes(2);
});
for (const resolveFetch of resolvers) {
resolveFetch({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
}
await expect(Promise.all([first, second])).resolves.toEqual([{ flags: [] }, { flags: [] }]);
});
});
describe('Multi-modal support', () => {
it('should format inputs correctly for omni-moderation models', async () => {
const provider = createProvider('omni-moderation-latest');
const mockResponse = {
id: 'modr-123',
model: 'omni-moderation-latest',
results: [{ flagged: false, categories: {}, category_scores: {} }],
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
await provider.callModerationApi('user input', 'assistant response');
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"type":"text"'),
}),
expect.any(Number),
'json',
true,
undefined,
);
});
it('should handle mixed text and image inputs', async () => {
const provider = createProvider('omni-moderation-latest');
const mockResponse = {
id: 'modr-123',
model: 'omni-moderation-latest',
results: [
{
flagged: true,
categories: {
violence: true,
},
category_scores: {
violence: 0.95,
},
category_applied_input_types: {
violence: ['image'],
},
},
],
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const imageInput: Array<TextInput | ImageInput> = [
{ type: 'text' as const, text: 'Some text' },
{
type: 'image_url' as const,
image_url: { url: 'https://example.com/image.png' },
},
];
const result = await provider.callModerationApi('user input', imageInput);
// Instead of checking for "applied to" text, just verify that we have flags
expect(result.flags!).toBeDefined();
expect(result.flags!.length).toBeGreaterThan(0);
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('image_url'),
}),
expect.any(Number),
'json',
true,
undefined,
);
});
it('should hash cached mixed text and image inputs without leaking raw content', async () => {
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const apiKey = 'sk-secret-moderation-key';
const headerSecret = 'Bearer moderation-header-secret';
const textSecret = 'multimodal-secret-text';
const imageUrl = 'https://example.com/image.png?token=image-secret-token';
const provider = new OpenAiModerationProvider('omni-moderation-latest', {
config: {
apiKey,
headers: {
Authorization: headerSecret,
},
},
});
const mockResponse = {
id: 'modr-123',
model: 'omni-moderation-latest',
results: [{ flagged: false, categories: {}, category_scores: {} }],
};
const mockCache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(getCache).mockImplementation(function () {
return mockCache as any;
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const imageInput: Array<TextInput | ImageInput> = [
{ type: 'text' as const, text: textSecret },
{
type: 'image_url' as const,
image_url: { url: imageUrl },
},
];
await provider.callModerationApi('user input', imageInput);
const cacheGetKey = mockCache.get.mock.calls[0][0] as string;
const cacheSetKey = mockCache.set.mock.calls[0][0] as string;
expect(cacheGetKey).toBe(cacheSetKey);
expect(cacheSetKey).toMatch(
/^openai:moderation:omni-moderation-latest:[a-f0-9]{64}:[a-f0-9]{64}:[a-f0-9]{64}$/,
);
expect(cacheSetKey).not.toContain(textSecret);
expect(cacheSetKey).not.toContain(imageUrl);
expect(cacheSetKey).not.toContain('image-secret-token');
expect(cacheSetKey).not.toContain(apiKey);
expect(cacheSetKey).not.toContain(headerSecret);
});
it('should show which input types triggered each flag', async () => {
const provider = createProvider('omni-moderation-latest');
const mockResponse = {
id: 'modr-123',
model: 'omni-moderation-latest',
results: [
{
flagged: true,
categories: {
violence: true,
sexual: true,
},
category_scores: {
violence: 0.95,
sexual: 0.98,
},
category_applied_input_types: {
violence: ['image'],
sexual: ['text', 'image'],
},
},
],
};
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockResponse,
status: 200,
statusText: 'OK',
cached: false,
});
const imageInput: Array<TextInput | ImageInput> = [
{ type: 'text' as const, text: 'Some text' },
{
type: 'image_url' as const,
image_url: { url: 'https://example.com/image.png' },
},
];
const result = await provider.callModerationApi('user input', imageInput);
const violenceFlag = result.flags?.find((f) => f.code === 'violence');
const sexualFlag = result.flags?.find((f) => f.code === 'sexual');
// Just verify that the flags exist with the correct codes
expect(violenceFlag).toBeDefined();
expect(sexualFlag).toBeDefined();
});
});
describe('Model configuration', () => {
it('should warn about unknown models', async () => {
// Import the mocked logger
const logger = (await import('../../../src/logger')).default;
const warnSpy = vi.spyOn(logger, 'warn');
new OpenAiModerationProvider('unknown-model', {
config: { apiKey: 'test-key' },
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('unknown OpenAI moderation model'),
);
});
it('should accept custom API headers', async () => {
const provider = new OpenAiModerationProvider('text-moderation-latest', {
config: {
apiKey: 'test-key',
headers: {
'Custom-Header': 'custom-value',
},
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: { id: 'modr-123', model: 'text-moderation-latest', results: [] },
status: 200,
statusText: 'OK',
cached: false,
});
await provider.callModerationApi('user', 'assistant');
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'Custom-Header': 'custom-value',
'x-promptfoo-silent': 'true',
}),
}),
expect.any(Number),
'json',
true,
undefined,
);
});
});
});
describe('Moderation Utility Functions', () => {
describe('Input Type Guards', () => {
it('should correctly identify text inputs', () => {
const textInput: TextInput = { type: 'text', text: 'test content' };
const imageInput: ImageInput = {
type: 'image_url',
image_url: { url: 'https://example.com/image.jpg' },
};
expect(isTextInput(textInput)).toBe(true);
expect(isTextInput(imageInput)).toBe(false);
});
it('should correctly identify image inputs', () => {
const textInput: TextInput = { type: 'text', text: 'test content' };
const imageInput: ImageInput = {
type: 'image_url',
image_url: { url: 'https://example.com/image.jpg' },
};
expect(isImageInput(imageInput)).toBe(true);
expect(isImageInput(textInput)).toBe(false);
});
});
describe('supportsImageInput', () => {
it('should return true for multi-modal models', () => {
expect(supportsImageInput('omni-moderation-latest')).toBe(true);
expect(supportsImageInput('omni-moderation-2024-09-26')).toBe(true);
});
it('should return false for text-only models', () => {
expect(supportsImageInput('text-moderation-latest')).toBe(false);
expect(supportsImageInput('text-moderation-stable')).toBe(false);
expect(supportsImageInput('text-moderation-007')).toBe(false);
});
it('should return false for unknown models', () => {
expect(supportsImageInput('nonexistent-model')).toBe(false);
});
});
describe('formatModerationInput', () => {
it('should return string as-is for text-only models', () => {
const input = 'test content';
expect(formatModerationInput(input, false)).toBe(input);
});
it('should wrap string in TextInput object for multi-modal models', () => {
const input = 'test content';
const expected = [{ type: 'text', text: input }];
expect(formatModerationInput(input, true)).toEqual(expected);
});
it('should return array inputs as-is for multi-modal models', () => {
const input = [
{ type: 'text' as const, text: 'text content' },
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
];
expect(formatModerationInput(input, true)).toEqual(input);
});
it('should filter out image inputs for text-only models', () => {
const input = [
{ type: 'text' as const, text: 'text content' },
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
];
expect(formatModerationInput(input, false)).toBe('text content');
});
it('should join multiple text inputs when filtering for text-only models', () => {
const input = [
{ type: 'text' as const, text: 'first text' },
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
{ type: 'text' as const, text: 'second text' },
];
expect(formatModerationInput(input, false)).toBe('first text second text');
});
it('should handle empty text strings when joining for text-only models', () => {
const input = [
{ type: 'text' as const, text: '' },
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
{ type: 'text' as const, text: 'second text' },
];
expect(formatModerationInput(input, false)).toBe(' second text');
});
it('should preserve leading and trailing whitespace in text inputs when joining', () => {
const input = [
{ type: 'text' as const, text: ' first text ' },
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
{ type: 'text' as const, text: 'second text' },
];
expect(formatModerationInput(input, false)).toBe(' first text second text');
});
it('should return single text input unchanged when mixed with images for text-only models', () => {
const input = [
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image-1.jpg' } },
{ type: 'text' as const, text: 'only text' },
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image-2.jpg' } },
];
expect(formatModerationInput(input, false)).toBe('only text');
});
});
});