0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
500 lines
17 KiB
TypeScript
500 lines
17 KiB
TypeScript
import './setup';
|
|
|
|
import { randomUUID } from 'crypto';
|
|
|
|
import { expect, it, vi } from 'vitest';
|
|
import { evaluate } from '../../src/evaluator';
|
|
import Eval from '../../src/models/eval';
|
|
import { type ApiProvider, type TestSuite } from '../../src/types/index';
|
|
import { mockApiProvider, toPrompt } from './helpers';
|
|
import { describeEvaluator } from './lifecycle';
|
|
|
|
describeEvaluator('evaluator prompt and provider routing', () => {
|
|
it('evaluate with providerPromptMap', async () => {
|
|
const testSuite: TestSuite = {
|
|
providers: [mockApiProvider],
|
|
prompts: [toPrompt('Test prompt 1'), toPrompt('Test prompt 2')],
|
|
providerPromptMap: {
|
|
'test-provider': ['Test prompt 1'],
|
|
},
|
|
tests: [
|
|
{
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
],
|
|
};
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
|
|
expect(summary.stats.successes).toBe(1);
|
|
expect(summary.stats.failures).toBe(0);
|
|
expect(summary.stats.tokenUsage).toEqual({
|
|
total: 10,
|
|
prompt: 5,
|
|
completion: 5,
|
|
cached: 0,
|
|
numRequests: 1,
|
|
completionDetails: {
|
|
reasoning: 0,
|
|
acceptedPrediction: 0,
|
|
rejectedPrediction: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
},
|
|
assertions: {
|
|
total: 0,
|
|
prompt: 0,
|
|
completion: 0,
|
|
cached: 0,
|
|
numRequests: 0,
|
|
completionDetails: {
|
|
reasoning: 0,
|
|
acceptedPrediction: 0,
|
|
rejectedPrediction: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
},
|
|
},
|
|
});
|
|
expect(summary.results[0].prompt.raw).toBe('Test prompt 1');
|
|
expect(summary.results[0].prompt.label).toBe('Test prompt 1');
|
|
expect(summary.results[0].response?.output).toBe('Test output');
|
|
});
|
|
|
|
it('evaluate with allowed prompts filtering', async () => {
|
|
const mockApiProvider: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('test-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{ raw: 'Test prompt 1', label: 'prompt1' },
|
|
{ raw: 'Test prompt 2', label: 'prompt2' },
|
|
{ raw: 'Test prompt 3', label: 'group1:prompt3' },
|
|
],
|
|
providerPromptMap: {
|
|
'test-provider': ['prompt1', 'group1'],
|
|
},
|
|
tests: [
|
|
{
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
],
|
|
};
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
|
|
expect(summary).toMatchObject({
|
|
stats: {
|
|
successes: 2,
|
|
failures: 0,
|
|
},
|
|
results: [{ prompt: { label: 'prompt1' } }, { prompt: { label: 'group1:prompt3' } }],
|
|
});
|
|
});
|
|
|
|
it('evaluate with labeled and unlabeled providers and providerPromptMap', async () => {
|
|
const mockLabeledProvider: ApiProvider = {
|
|
id: () => 'labeled-provider-id',
|
|
label: 'Labeled Provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Labeled Provider Output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const mockUnlabeledProvider: ApiProvider = {
|
|
id: () => 'unlabeled-provider-id',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Unlabeled Provider Output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockLabeledProvider, mockUnlabeledProvider],
|
|
prompts: [
|
|
{
|
|
raw: 'Prompt 1',
|
|
label: 'prompt1',
|
|
},
|
|
{
|
|
raw: 'Prompt 2',
|
|
label: 'prompt2',
|
|
},
|
|
],
|
|
providerPromptMap: {
|
|
'Labeled Provider': ['prompt1'],
|
|
'unlabeled-provider-id': ['prompt2'],
|
|
},
|
|
};
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
expect(summary).toMatchObject({
|
|
stats: expect.objectContaining({
|
|
successes: 2,
|
|
failures: 0,
|
|
}),
|
|
results: [
|
|
expect.objectContaining({
|
|
provider: expect.objectContaining({
|
|
id: 'labeled-provider-id',
|
|
label: 'Labeled Provider',
|
|
}),
|
|
response: expect.objectContaining({
|
|
output: 'Labeled Provider Output',
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
provider: expect.objectContaining({
|
|
id: 'unlabeled-provider-id',
|
|
label: undefined,
|
|
}),
|
|
response: expect.objectContaining({
|
|
output: 'Unlabeled Provider Output',
|
|
}),
|
|
}),
|
|
],
|
|
});
|
|
expect(evalRecord.prompts).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
provider: 'Labeled Provider',
|
|
}),
|
|
expect.objectContaining({
|
|
provider: 'unlabeled-provider-id',
|
|
}),
|
|
]),
|
|
);
|
|
|
|
expect(mockLabeledProvider.callApi).toHaveBeenCalledTimes(1);
|
|
expect(mockUnlabeledProvider.callApi).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('evaluate with test-level providers filter', async () => {
|
|
const mockProvider1: ApiProvider = {
|
|
id: () => 'provider-1',
|
|
label: 'fast-model',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Fast Output',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const mockProvider2: ApiProvider = {
|
|
id: () => 'provider-2',
|
|
label: 'smart-model',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Smart Output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockProvider1, mockProvider2],
|
|
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
|
|
tests: [
|
|
{
|
|
description: 'fast test',
|
|
vars: { input: 'simple' },
|
|
providers: ['fast-model'], // Only run on fast-model
|
|
},
|
|
{
|
|
description: 'smart test',
|
|
vars: { input: 'complex' },
|
|
providers: ['smart-model'], // Only run on smart-model
|
|
},
|
|
{
|
|
description: 'all providers test',
|
|
vars: { input: 'general' },
|
|
// No providers filter - runs on both
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
// 1 test on fast-model + 1 test on smart-model + 2 tests (1 on each provider) = 4 total
|
|
expect(summary.stats.successes).toBe(4);
|
|
expect(mockProvider1.callApi).toHaveBeenCalledTimes(2); // fast test + all providers test
|
|
expect(mockProvider2.callApi).toHaveBeenCalledTimes(2); // smart test + all providers test
|
|
});
|
|
|
|
it('evaluate with test-level providers filter using wildcard', async () => {
|
|
const openaiProvider: ApiProvider = {
|
|
id: () => 'openai:gpt-4',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'OpenAI Output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const anthropicProvider: ApiProvider = {
|
|
id: () => 'anthropic:claude-3',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Anthropic Output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [openaiProvider, anthropicProvider],
|
|
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
|
|
tests: [
|
|
{
|
|
vars: { input: 'test' },
|
|
providers: ['openai:*'], // Wildcard - only run on openai providers
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
expect(summary.stats.successes).toBe(1);
|
|
expect(openaiProvider.callApi).toHaveBeenCalledTimes(1);
|
|
expect(anthropicProvider.callApi).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it('evaluate inherits providers filter from defaultTest', async () => {
|
|
const provider1: ApiProvider = {
|
|
id: () => 'provider-1',
|
|
label: 'default-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output 1',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const provider2: ApiProvider = {
|
|
id: () => 'provider-2',
|
|
label: 'other-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output 2',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [provider1, provider2],
|
|
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
|
|
defaultTest: {
|
|
providers: ['default-provider'], // Default to only default-provider
|
|
},
|
|
tests: [
|
|
{
|
|
vars: { input: 'test1' },
|
|
// Inherits providers filter from defaultTest
|
|
},
|
|
{
|
|
vars: { input: 'test2' },
|
|
providers: ['other-provider'], // Override defaultTest
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
expect(summary.stats.successes).toBe(2);
|
|
expect(provider1.callApi).toHaveBeenCalledTimes(1); // test1 only
|
|
expect(provider2.callApi).toHaveBeenCalledTimes(1); // test2 only
|
|
});
|
|
|
|
it('evaluate with empty providers array blocks all providers', async () => {
|
|
const mockProvider: ApiProvider = {
|
|
id: () => 'test-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockProvider],
|
|
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
|
|
tests: [
|
|
{
|
|
vars: { input: 'test' },
|
|
providers: [], // Empty array = block all
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
expect(summary.stats.successes).toBe(0);
|
|
expect(mockProvider.callApi).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it('evaluate with providers filter and providerPromptMap combined', async () => {
|
|
const provider1: ApiProvider = {
|
|
id: () => 'provider-1',
|
|
label: 'provider-one',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output 1',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const provider2: ApiProvider = {
|
|
id: () => 'provider-2',
|
|
label: 'provider-two',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output 2',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [provider1, provider2],
|
|
prompts: [
|
|
{ raw: 'Prompt A', label: 'prompt-a' },
|
|
{ raw: 'Prompt B', label: 'prompt-b' },
|
|
],
|
|
providerPromptMap: {
|
|
'provider-one': ['prompt-a'], // provider-one only runs prompt-a
|
|
'provider-two': ['prompt-b'], // provider-two only runs prompt-b
|
|
},
|
|
tests: [
|
|
{
|
|
vars: { input: 'test' },
|
|
providers: ['provider-one'], // Only run on provider-one
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
// providers filter limits to provider-one
|
|
// providerPromptMap limits provider-one to prompt-a
|
|
// Result: 1 test case (provider-one + prompt-a)
|
|
expect(summary.stats.successes).toBe(1);
|
|
expect(provider1.callApi).toHaveBeenCalledTimes(1);
|
|
expect(provider2.callApi).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it('promptIdx aligns with prompts array when test-level providers filter skips providers', async () => {
|
|
// This test verifies that promptIdx correctly maps to the prompts array
|
|
// even when test-level provider filtering causes some providers to be skipped.
|
|
// Before the fix, promptIdx was a sequential counter that could misalign with
|
|
// the prompts array when filters caused gaps.
|
|
const provider1: ApiProvider = {
|
|
id: () => 'provider-1',
|
|
label: 'model-a',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output from model-a',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const provider2: ApiProvider = {
|
|
id: () => 'provider-2',
|
|
label: 'model-b',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output from model-b',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [provider1, provider2],
|
|
prompts: [toPrompt('Prompt X'), toPrompt('Prompt Y')],
|
|
tests: [
|
|
{
|
|
vars: { input: 'test' },
|
|
providers: ['model-b'], // Only run on provider2, skip provider1
|
|
},
|
|
],
|
|
};
|
|
|
|
// prompts array should be: [model-a+PromptX(0), model-a+PromptY(1), model-b+PromptX(2), model-b+PromptY(3)]
|
|
// With provider filter, only model-b runs, so promptIdx should be 2 and 3 (not 0 and 1)
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
expect(summary.stats.successes).toBe(2);
|
|
expect(provider1.callApi).toHaveBeenCalledTimes(0);
|
|
expect(provider2.callApi).toHaveBeenCalledTimes(2);
|
|
|
|
// Verify promptIdx values map to the correct provider in the prompts array
|
|
const results = summary.results.sort((a, b) => a.promptIdx - b.promptIdx);
|
|
expect(results).toHaveLength(2);
|
|
// Both results should have promptIdx >= 2 (mapping to provider2's entries)
|
|
for (const result of results) {
|
|
expect(result.promptIdx).toBeGreaterThanOrEqual(2);
|
|
expect(result.provider.id).toBe('provider-2');
|
|
}
|
|
});
|
|
|
|
it('promptIdx aligns with prompts array when test-level prompts filter skips prompts', async () => {
|
|
const provider1: ApiProvider = {
|
|
id: () => 'provider-1',
|
|
label: 'model-a',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output from model-a',
|
|
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const provider2: ApiProvider = {
|
|
id: () => 'provider-2',
|
|
label: 'model-b',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Output from model-b',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [provider1, provider2],
|
|
prompts: [
|
|
{ raw: 'Prompt X', label: 'promptX' },
|
|
{ raw: 'Prompt Y', label: 'promptY' },
|
|
],
|
|
tests: [
|
|
{
|
|
vars: { input: 'test' },
|
|
prompts: ['promptY'], // Only run with Prompt Y, skip Prompt X
|
|
},
|
|
],
|
|
};
|
|
|
|
// prompts array: [model-a+promptX(0), model-a+promptY(1), model-b+promptX(2), model-b+promptY(3)]
|
|
// With prompt filter, only promptY runs, so promptIdx should be 1 and 3 (not 0 and 1)
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
await evaluate(testSuite, evalRecord, {});
|
|
const summary = await evalRecord.toEvaluateSummary();
|
|
|
|
expect(summary.stats.successes).toBe(2);
|
|
expect(provider1.callApi).toHaveBeenCalledTimes(1);
|
|
expect(provider2.callApi).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify promptIdx values map to the correct prompt entries (odd indices for promptY)
|
|
const results = summary.results.sort((a, b) => a.promptIdx - b.promptIdx);
|
|
expect(results).toHaveLength(2);
|
|
// promptIdx should be 1 (model-a+promptY) and 3 (model-b+promptY)
|
|
expect(results[0].promptIdx).toBe(1);
|
|
expect(results[1].promptIdx).toBe(3);
|
|
});
|
|
});
|