chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,976 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user