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
977 lines
28 KiB
TypeScript
977 lines
28 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
disableCache,
|
|
enableCache,
|
|
fetchWithCache,
|
|
getCache,
|
|
isCacheEnabled,
|
|
} from '../../src/cache';
|
|
import logger from '../../src/logger';
|
|
import {
|
|
DefaultModerationProvider,
|
|
ReplicateImageProvider,
|
|
ReplicateModerationProvider,
|
|
ReplicateProvider,
|
|
} from '../../src/providers/replicate';
|
|
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
|
|
import { mockProcessEnv } from '../util/utils';
|
|
|
|
vi.mock('../../src/cache');
|
|
vi.mock('../../src/logger', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockedFetchWithCache = vi.mocked(fetchWithCache);
|
|
|
|
describe('ReplicateProvider', () => {
|
|
const mockApiKey = 'test-api-key';
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
disableCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
enableCache();
|
|
});
|
|
|
|
it('should handle successful API calls', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'test response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toBe('test response');
|
|
const requestBody = JSON.parse(
|
|
(mockedFetchWithCache.mock.calls[0][1] as { body: string }).body,
|
|
);
|
|
expect(requestBody.input).not.toHaveProperty('apiKey');
|
|
expect(mockedFetchWithCache).toHaveBeenCalledWith(
|
|
'https://api.replicate.com/v1/models/test-model/predictions',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: `Bearer ${mockApiKey}`,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'wait=60',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
);
|
|
});
|
|
|
|
it('should preserve explicit zero-valued config instead of replacing it with env defaults', async () => {
|
|
const originalTemperature = process.env.REPLICATE_TEMPERATURE;
|
|
const originalSeed = process.env.REPLICATE_SEED;
|
|
mockProcessEnv({ REPLICATE_TEMPERATURE: '0.9' });
|
|
mockProcessEnv({ REPLICATE_SEED: '123' });
|
|
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'test response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
try {
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: {
|
|
apiKey: mockApiKey,
|
|
temperature: 0,
|
|
seed: 0,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
const request = mockedFetchWithCache.mock.calls[0] as [string, { body: string }];
|
|
const body = JSON.parse(request[1].body);
|
|
|
|
expect(body.input.temperature).toBe(0);
|
|
expect(body.input.seed).toBe(0);
|
|
} finally {
|
|
if (originalTemperature === undefined) {
|
|
mockProcessEnv({ REPLICATE_TEMPERATURE: undefined });
|
|
} else {
|
|
mockProcessEnv({ REPLICATE_TEMPERATURE: originalTemperature });
|
|
}
|
|
|
|
if (originalSeed === undefined) {
|
|
mockProcessEnv({ REPLICATE_SEED: undefined });
|
|
} else {
|
|
mockProcessEnv({ REPLICATE_SEED: originalSeed });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should handle API errors', async () => {
|
|
mockedFetchWithCache.mockRejectedValue(new Error('API Error'));
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.error).toBe('API call error: Error: API Error');
|
|
});
|
|
|
|
it('should handle prompt prefix and suffix', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'test response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: {
|
|
apiKey: mockApiKey,
|
|
prompt: {
|
|
prefix: 'prefix_',
|
|
suffix: '_suffix',
|
|
},
|
|
},
|
|
});
|
|
|
|
await provider.callApi('test');
|
|
expect(mockedFetchWithCache).toHaveBeenCalledWith(
|
|
'https://api.replicate.com/v1/models/test-model/predictions',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"prompt":"prefix_test_suffix"'),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
);
|
|
});
|
|
|
|
it('should poll for completion when prediction is still processing', async () => {
|
|
// First call returns processing status
|
|
mockedFetchWithCache.mockResolvedValueOnce({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'processing',
|
|
output: null,
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
// Second call (polling) returns completed
|
|
mockedFetchWithCache.mockResolvedValueOnce({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'test response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toBe('test response');
|
|
expect(mockedFetchWithCache).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should handle array outputs', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: ['Hello', ' ', 'World'],
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toBe('Hello World');
|
|
});
|
|
|
|
it('should handle failed predictions', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'failed',
|
|
error: 'Model error',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.error).toBe('API call error: Error: Model error');
|
|
});
|
|
|
|
it('should use versioned endpoint for models with version IDs', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'test response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateProvider('test-model:version123', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
await provider.callApi('test prompt');
|
|
expect(mockedFetchWithCache).toHaveBeenCalledWith(
|
|
'https://api.replicate.com/v1/predictions',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: expect.stringContaining('"version":"version123"'),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
);
|
|
});
|
|
|
|
it('should set cached flag when returning cached response', async () => {
|
|
const mockCachedResponse = {
|
|
output: 'cached PFQA_REPLICATE_CACHED_OUTPUT_SECRET response',
|
|
tokenUsage: { total: 100 },
|
|
};
|
|
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(JSON.stringify(mockCachedResponse)),
|
|
set: vi.fn(),
|
|
} as any;
|
|
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockResolvedValue(mockCache);
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
expect(result.cached).toBe(true);
|
|
expect(result.output).toBe('cached PFQA_REPLICATE_CACHED_OUTPUT_SECRET response');
|
|
expect(mockCache.get).toHaveBeenCalled();
|
|
const debugLogs = vi.mocked(logger.debug).mock.calls.map((call) => JSON.stringify(call));
|
|
expect(debugLogs.join('\n')).not.toContain('PFQA_REPLICATE_CACHED_OUTPUT_SECRET');
|
|
expect(debugLogs.join('\n')).not.toContain('test prompt');
|
|
// Verify fetchWithCache was not called because cache was used
|
|
expect(mockedFetchWithCache).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should cache successful string responses', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'test response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockResolvedValue(mockCache);
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const prompt = 'PFQA_REPLICATE_PROMPT_SENTINEL';
|
|
const result = await provider.callApi(prompt);
|
|
|
|
expect(result.output).toBe('test response');
|
|
expect(mockCache.set).toHaveBeenCalledTimes(1);
|
|
const cacheKey = mockCache.set.mock.calls[0][0] as string;
|
|
expect(cacheKey).toMatch(/^replicate:test-model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain(prompt);
|
|
expect(cacheKey).not.toContain(mockApiKey);
|
|
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);
|
|
expect(mockCache.set).toHaveBeenCalledWith(cacheKey, expect.any(String));
|
|
expect(JSON.parse(mockCache.set.mock.calls[0][1])).toEqual({
|
|
output: 'test response',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
});
|
|
});
|
|
|
|
it('should not expose API key values in hashed cache keys', async () => {
|
|
mockedFetchWithCache
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
id: 'test-id-a',
|
|
status: 'succeeded',
|
|
output: 'tenant a response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
id: 'test-id-b',
|
|
status: 'succeeded',
|
|
output: 'tenant b response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockResolvedValue(mockCache);
|
|
|
|
const providerA = new ReplicateProvider('test-model', {
|
|
config: { apiKey: 'replicate-tenant-a-secret' },
|
|
});
|
|
const providerB = new ReplicateProvider('test-model', {
|
|
config: { apiKey: 'replicate-tenant-b-secret' },
|
|
});
|
|
|
|
await providerA.callApi('Shared replicate prompt');
|
|
await providerB.callApi('Shared replicate prompt');
|
|
|
|
const cacheKeyA = mockCache.get.mock.calls[0][0] as string;
|
|
const cacheKeyB = mockCache.get.mock.calls[1][0] as string;
|
|
expect(cacheKeyA).toMatch(/^replicate:test-model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKeyB).toMatch(/^replicate:test-model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKeyA).not.toBe(cacheKeyB);
|
|
expect(mockedFetchWithCache).toHaveBeenCalledTimes(2);
|
|
for (const cacheKey of [cacheKeyA, cacheKeyB]) {
|
|
expect(cacheKey).not.toContain('Shared replicate prompt');
|
|
expect(cacheKey).not.toContain('replicate-tenant-a-secret');
|
|
expect(cacheKey).not.toContain('replicate-tenant-b-secret');
|
|
}
|
|
});
|
|
|
|
it('should use a stable cache namespace for the same API key', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'stable tenant response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockResolvedValue(mockCache);
|
|
|
|
const providerA = new ReplicateProvider('test-model', {
|
|
config: { apiKey: 'replicate-stable-tenant-secret' },
|
|
});
|
|
const providerB = new ReplicateProvider('test-model', {
|
|
config: { apiKey: 'replicate-stable-tenant-secret' },
|
|
});
|
|
|
|
await providerA.callApi('Shared replicate prompt');
|
|
await providerB.callApi('Shared replicate prompt');
|
|
|
|
const cacheKeyA = mockCache.get.mock.calls[0][0] as string;
|
|
const cacheKeyB = mockCache.get.mock.calls[1][0] as string;
|
|
expect(cacheKeyA).toBe(cacheKeyB);
|
|
expect(cacheKeyA).toMatch(/^replicate:test-model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKeyA).not.toContain('replicate-stable-tenant-secret');
|
|
expect(cacheKeyA).not.toContain('Shared replicate prompt');
|
|
});
|
|
|
|
it('should canonicalize config order when hashing cache keys', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'canonical config response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockResolvedValue(mockCache);
|
|
|
|
const providerA = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey, temperature: 0.1, top_p: 0.9 },
|
|
});
|
|
const providerB = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey, top_p: 0.9, temperature: 0.1 },
|
|
});
|
|
|
|
await providerA.callApi('Shared canonical prompt');
|
|
await providerB.callApi('Shared canonical prompt');
|
|
|
|
expect(mockCache.get.mock.calls[0][0]).toBe(mockCache.get.mock.calls[1][0]);
|
|
});
|
|
|
|
it('should separate cache keys for env-backed generation defaults', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'env default response',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockResolvedValue(mockCache);
|
|
|
|
const provider = new ReplicateProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
vi.stubEnv('REPLICATE_TEMPERATURE', '0.1');
|
|
await provider.callApi('Shared env prompt');
|
|
vi.stubEnv('REPLICATE_TEMPERATURE', '0.9');
|
|
await provider.callApi('Shared env prompt');
|
|
|
|
const cacheKeyA = mockCache.get.mock.calls[0][0] as string;
|
|
const cacheKeyB = mockCache.get.mock.calls[1][0] as string;
|
|
expect(cacheKeyA).not.toBe(cacheKeyB);
|
|
expect(cacheKeyA).not.toContain('Shared env prompt');
|
|
expect(cacheKeyA).not.toContain(mockApiKey);
|
|
});
|
|
});
|
|
|
|
describe('ReplicateModerationProvider', () => {
|
|
const mockApiKey = 'test-api-key';
|
|
const reportedTokenUsage = {
|
|
total: 10,
|
|
prompt: 6,
|
|
completion: 4,
|
|
cached: 0,
|
|
numRequests: 1,
|
|
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
disableCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
enableCache();
|
|
});
|
|
|
|
it('should handle safe content correctly', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'safe\n',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callModerationApi('safe prompt', 'safe response');
|
|
expect(result.flags).toEqual([]);
|
|
});
|
|
|
|
it('should forward non-zero token usage reported by the underlying completion', async () => {
|
|
// Prove that callModerationApi preserves provider-reported usage unchanged.
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
vi.spyOn(provider, 'callApi').mockResolvedValue({
|
|
output: 'unsafe\nS1',
|
|
tokenUsage: reportedTokenUsage,
|
|
});
|
|
|
|
const result = await provider.callModerationApi('unsafe prompt', 'unsafe response');
|
|
expect(result.flags).toHaveLength(1);
|
|
expect(result.tokenUsage).toEqual(reportedTokenUsage);
|
|
});
|
|
|
|
it('should forward token usage from the LlamaGuard completion path', async () => {
|
|
// Exercise callApiInternal and verify the moderation wrapper preserves the
|
|
// provider response's tokenUsage object without inferring different values.
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'unsafe\nS1',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callModerationApi('unsafe prompt', 'unsafe response');
|
|
expect(result.flags).toHaveLength(1);
|
|
expect(result.tokenUsage).toEqual(createEmptyTokenUsage());
|
|
});
|
|
|
|
it('should forward provider-reported token usage with upstream errors', async () => {
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
vi.spyOn(provider, 'callApi').mockResolvedValue({
|
|
error: 'provider unavailable',
|
|
tokenUsage: reportedTokenUsage,
|
|
});
|
|
|
|
await expect(provider.callModerationApi('unsafe prompt', 'unsafe response')).resolves.toEqual({
|
|
error: 'provider unavailable',
|
|
tokenUsage: reportedTokenUsage,
|
|
});
|
|
});
|
|
|
|
it('should forward provider-reported token usage with invalid outputs', async () => {
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
vi.spyOn(provider, 'callApi').mockResolvedValue({
|
|
output: null,
|
|
tokenUsage: reportedTokenUsage,
|
|
});
|
|
|
|
await expect(provider.callModerationApi('unsafe prompt', 'unsafe response')).resolves.toEqual({
|
|
error: 'Invalid moderation response: null',
|
|
tokenUsage: reportedTokenUsage,
|
|
});
|
|
});
|
|
|
|
it('should handle unsafe content with categories', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: 'unsafe\nS1,S3',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callModerationApi('unsafe prompt', 'unsafe response');
|
|
expect(result.flags).toHaveLength(2);
|
|
expect(result.flags![0]).toEqual({
|
|
code: 'S1',
|
|
description: 'Violent Crimes',
|
|
confidence: 1.0,
|
|
});
|
|
expect(result.flags![1]).toEqual({
|
|
code: 'S3',
|
|
description: 'Sex Crimes',
|
|
confidence: 1.0,
|
|
});
|
|
});
|
|
|
|
it('should handle LlamaGuard 3 categories (S1-S13)', async () => {
|
|
const llamaGuard3Categories = [
|
|
'S1',
|
|
'S2',
|
|
'S3',
|
|
'S4',
|
|
'S5',
|
|
'S6',
|
|
'S7',
|
|
'S8',
|
|
'S9',
|
|
'S10',
|
|
'S11',
|
|
'S12',
|
|
'S13',
|
|
];
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: `unsafe\n${llamaGuard3Categories.join(',')}`,
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callModerationApi('test prompt', 'test response');
|
|
expect(result.flags).toHaveLength(13);
|
|
|
|
result.flags!.forEach((flag, index) => {
|
|
expect(flag.code).toBe(llamaGuard3Categories[index]);
|
|
expect(flag.confidence).toBe(1.0);
|
|
});
|
|
});
|
|
|
|
it('should handle all LlamaGuard categories (including S14 for LlamaGuard 4)', async () => {
|
|
const allCategories = [
|
|
'S1',
|
|
'S2',
|
|
'S3',
|
|
'S4',
|
|
'S5',
|
|
'S6',
|
|
'S7',
|
|
'S8',
|
|
'S9',
|
|
'S10',
|
|
'S11',
|
|
'S12',
|
|
'S13',
|
|
'S14', // LlamaGuard 4 only
|
|
];
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: `unsafe\n${allCategories.join(',')}`,
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callModerationApi('test prompt', 'test response');
|
|
expect(result.flags).toHaveLength(14); // All 14 categories including S14
|
|
|
|
result.flags!.forEach((flag, index) => {
|
|
expect(flag.code).toBe(allCategories[index]);
|
|
expect(flag.confidence).toBe(1.0);
|
|
// S14 should have its description
|
|
if (flag.code === 'S14') {
|
|
expect(flag.description).toBe('Code Interpreter Abuse');
|
|
}
|
|
});
|
|
});
|
|
|
|
it('should handle malformed responses', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: null,
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateModerationProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callModerationApi('test prompt', 'test response');
|
|
expect(result.error).toContain('Unsupported response from Replicate');
|
|
});
|
|
});
|
|
|
|
describe('DefaultModerationProvider', () => {
|
|
it('should be configured with LlamaGuard 4', () => {
|
|
expect(DefaultModerationProvider.modelName).toBe('meta/llama-guard-4-12b');
|
|
// LlamaGuard 4 is the default on Replicate
|
|
});
|
|
});
|
|
|
|
describe('ReplicateImageProvider', () => {
|
|
const mockApiKey = 'test-api-key';
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
disableCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
enableCache();
|
|
});
|
|
|
|
it('should handle successful image generation', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: ['https://example.com/image.png'],
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateImageProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('a beautiful sunset');
|
|
expect(result.output).toBe('');
|
|
});
|
|
|
|
it('should handle custom width and height', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: ['https://example.com/image.png'],
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateImageProvider('test-model', {
|
|
config: {
|
|
apiKey: mockApiKey,
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('test prompt');
|
|
expect(mockedFetchWithCache).toHaveBeenCalledWith(
|
|
'https://api.replicate.com/v1/models/test-model/predictions',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"width":1024'),
|
|
}),
|
|
expect.any(Number),
|
|
'json',
|
|
);
|
|
});
|
|
|
|
it('should handle failed image generation', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'failed',
|
|
error: 'Image generation failed',
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateImageProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.error).toBe('Image generation failed');
|
|
});
|
|
|
|
it('should ellipsize long prompts in markdown', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: ['https://example.com/image.png'],
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const provider = new ReplicateImageProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
const longPrompt = 'a'.repeat(100);
|
|
const result = await provider.callApi(longPrompt);
|
|
expect(result.output).toMatch(/!\[.*\.\.\.\]/);
|
|
});
|
|
|
|
it('should hash prompt and config values in image cache keys', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: ['https://example.com/image.png'],
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockReturnValue(mockCache);
|
|
|
|
const provider = new ReplicateImageProvider('test-model', {
|
|
config: {
|
|
apiKey: mockApiKey,
|
|
negative_prompt: 'PFQA_REPLICATE_IMAGE_CONFIG_SENTINEL',
|
|
},
|
|
});
|
|
|
|
const prompt = 'PFQA_REPLICATE_IMAGE_PROMPT_SENTINEL';
|
|
const result = await provider.callApi(prompt);
|
|
|
|
expect(result.cached).toBe(false);
|
|
const cacheKey = mockCache.set.mock.calls[0][0] as string;
|
|
expect(cacheKey).toMatch(/^replicate:image:test-model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain(prompt);
|
|
expect(cacheKey).not.toContain('PFQA_REPLICATE_IMAGE_CONFIG_SENTINEL');
|
|
expect(cacheKey).not.toContain(mockApiKey);
|
|
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);
|
|
});
|
|
|
|
it('should handle circular image context when hashing cache keys', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: ['https://example.com/image.png'],
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockReturnValue(mockCache);
|
|
|
|
const provider = new ReplicateImageProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
const context: any = {
|
|
vars: { apiKey: 'PFQA_REPLICATE_CONTEXT_API_KEY_SECRET' },
|
|
filters: { uppercase: () => 'SECRET_FILTER_RESULT' },
|
|
getCache: vi.fn(),
|
|
logger: { debug: vi.fn(), secret: 'PFQA_REPLICATE_LOGGER_SECRET' },
|
|
};
|
|
context.self = context;
|
|
context.originalProvider = provider;
|
|
|
|
await expect(
|
|
provider.callApi('PFQA_REPLICATE_CIRCULAR_CONTEXT_PROMPT', context),
|
|
).resolves.toEqual(expect.objectContaining({ cached: false }));
|
|
|
|
const cacheKey = mockCache.set.mock.calls[0][0] as string;
|
|
expect(cacheKey).toMatch(/^replicate:image:test-model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain('PFQA_REPLICATE_CONTEXT_API_KEY_SECRET');
|
|
expect(cacheKey).not.toContain('PFQA_REPLICATE_CIRCULAR_CONTEXT_PROMPT');
|
|
expect(cacheKey).not.toContain('PFQA_REPLICATE_LOGGER_SECRET');
|
|
});
|
|
|
|
it('should ignore unused image context when computing cache keys', async () => {
|
|
mockedFetchWithCache.mockResolvedValue({
|
|
data: {
|
|
id: 'test-id',
|
|
status: 'succeeded',
|
|
output: ['https://example.com/image.png'],
|
|
},
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
} as any;
|
|
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(getCache).mockReturnValue(mockCache);
|
|
|
|
const provider = new ReplicateImageProvider('test-model', {
|
|
config: { apiKey: mockApiKey },
|
|
});
|
|
|
|
await provider.callApi('PFQA_REPLICATE_IMAGE_CONTEXT_PROMPT', {
|
|
vars: { tenant: 'PFQA_REPLICATE_IMAGE_CONTEXT_TENANT_A' },
|
|
} as any);
|
|
await provider.callApi('PFQA_REPLICATE_IMAGE_CONTEXT_PROMPT', {
|
|
vars: { tenant: 'PFQA_REPLICATE_IMAGE_CONTEXT_TENANT_B' },
|
|
} as any);
|
|
|
|
const firstCacheKey = mockCache.get.mock.calls[0][0] as string;
|
|
const secondCacheKey = mockCache.get.mock.calls[1][0] as string;
|
|
expect(firstCacheKey).toBe(secondCacheKey);
|
|
expect(firstCacheKey).not.toContain('PFQA_REPLICATE_IMAGE_CONTEXT_PROMPT');
|
|
expect(firstCacheKey).not.toContain('PFQA_REPLICATE_IMAGE_CONTEXT_TENANT_A');
|
|
expect(secondCacheKey).not.toContain('PFQA_REPLICATE_IMAGE_CONTEXT_TENANT_B');
|
|
});
|
|
});
|