Files
promptfoo--promptfoo/test/providers/replicate.test.ts
T
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

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('![a beautiful sunset](https://example.com/image.png)');
});
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');
});
});