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
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { runEval } from '../src/evaluator';
|
|
import { createEmptyTokenUsage } from '../src/util/tokenUsageUtils';
|
|
|
|
import type { ApiProvider, Prompt, TestCase } from '../src/types/index';
|
|
|
|
describe('Per-test configuration merging', () => {
|
|
let mockProvider: ApiProvider;
|
|
let mockPrompt: Prompt;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
// Mock provider that captures the config it receives
|
|
mockProvider = {
|
|
id: vi.fn(() => 'test-provider'),
|
|
label: 'Test Provider',
|
|
config: {
|
|
temperature: 0.5,
|
|
provider_field: 'provider_value',
|
|
},
|
|
callApi: vi.fn(async (_prompt, context) => {
|
|
// Return the config so we can verify it was merged correctly
|
|
return {
|
|
output: JSON.stringify({ received_config: context?.prompt?.config }),
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
};
|
|
}),
|
|
};
|
|
|
|
mockPrompt = {
|
|
id: 'test-prompt',
|
|
raw: 'Test prompt: {{input}}',
|
|
label: 'Test Prompt',
|
|
config: {
|
|
prompt_field: 'prompt_value',
|
|
temperature: 0.7, // Override provider
|
|
},
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('should merge test.options into prompt.config', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
test_field: 'test_value',
|
|
},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(mockProvider.callApi).toHaveBeenCalled();
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Verify config merge
|
|
expect(context.prompt.config).toMatchObject({
|
|
prompt_field: 'prompt_value',
|
|
temperature: 0.7,
|
|
test_field: 'test_value', // Added from test.options
|
|
});
|
|
});
|
|
|
|
it('should allow test.options to override prompt.config', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
temperature: 1.0, // Override prompt's temperature
|
|
response_format: { type: 'json_object' },
|
|
},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Test options should override prompt config
|
|
expect(context.prompt.config.temperature).toBe(1.0);
|
|
expect(context.prompt.config.response_format).toEqual({ type: 'json_object' });
|
|
// Prompt field should still be there
|
|
expect(context.prompt.config.prompt_field).toBe('prompt_value');
|
|
});
|
|
|
|
it('should do shallow merge (replace nested objects)', async () => {
|
|
mockPrompt.config = {
|
|
nested: {
|
|
field1: 'value1',
|
|
field2: 'value2',
|
|
},
|
|
};
|
|
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
nested: {
|
|
field2: 'override', // Only field2
|
|
},
|
|
},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Shallow merge: nested object is completely replaced
|
|
expect(context.prompt.config.nested).toEqual({
|
|
field2: 'override',
|
|
});
|
|
// field1 is lost (not deep merged)
|
|
expect(context.prompt.config.nested.field1).toBeUndefined();
|
|
});
|
|
|
|
it('should handle undefined test.options', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
// No options
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Should use prompt config as-is
|
|
expect(context.prompt.config).toMatchObject({
|
|
prompt_field: 'prompt_value',
|
|
temperature: 0.7,
|
|
});
|
|
});
|
|
|
|
it('should handle empty test.options object', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Should use prompt config as-is
|
|
expect(context.prompt.config).toMatchObject({
|
|
prompt_field: 'prompt_value',
|
|
temperature: 0.7,
|
|
});
|
|
});
|
|
|
|
it('should preserve non-overlapping fields from both configs', async () => {
|
|
mockPrompt.config = {
|
|
temperature: 0.5,
|
|
max_tokens: 100,
|
|
};
|
|
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
response_format: { type: 'json_object' },
|
|
top_p: 0.9,
|
|
},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// All fields should be present
|
|
expect(context.prompt.config).toMatchObject({
|
|
temperature: 0.5,
|
|
max_tokens: 100,
|
|
response_format: { type: 'json_object' },
|
|
top_p: 0.9,
|
|
});
|
|
});
|
|
|
|
it('should support provider-specific fields in test.options', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
// OpenAI-specific field
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: 'test_schema',
|
|
strict: true,
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
required: ['answer'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Provider-specific field should be passed through
|
|
expect(context.prompt.config.response_format).toBeDefined();
|
|
expect(context.prompt.config.response_format.type).toBe('json_schema');
|
|
expect(context.prompt.config.response_format.json_schema).toBeDefined();
|
|
});
|
|
|
|
it('should support Google Vertex responseSchema in test.options', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
// Google-specific field
|
|
responseSchema: {
|
|
type: 'OBJECT',
|
|
properties: {
|
|
answer: { type: 'STRING' },
|
|
confidence: { type: 'NUMBER' },
|
|
},
|
|
required: ['answer'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Google-specific field should be passed through
|
|
expect(context.prompt.config.responseSchema).toBeDefined();
|
|
expect(context.prompt.config.responseSchema.type).toBe('OBJECT');
|
|
expect(context.prompt.config.responseSchema.properties.answer.type).toBe('STRING');
|
|
});
|
|
|
|
it('should preserve existing test.options behavior (transform, storeOutputAs)', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
transform: 'output.toUpperCase()',
|
|
storeOutputAs: 'result',
|
|
response_format: { type: 'json_object' },
|
|
},
|
|
};
|
|
|
|
// These fields should still be accessible in test.options AND in prompt.config
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// Fields should be in prompt.config
|
|
expect(context.prompt.config.transform).toBe('output.toUpperCase()');
|
|
expect(context.prompt.config.storeOutputAs).toBe('result');
|
|
expect(context.prompt.config.response_format).toEqual({ type: 'json_object' });
|
|
});
|
|
|
|
it('should handle multiple test.options fields simultaneously', async () => {
|
|
const test: TestCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
response_format: { type: 'json_object' },
|
|
temperature: 0.9,
|
|
max_tokens: 500,
|
|
top_p: 0.95,
|
|
custom_field: 'custom_value',
|
|
},
|
|
};
|
|
|
|
const _result = await runEval({
|
|
provider: mockProvider,
|
|
prompt: mockPrompt,
|
|
test,
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
});
|
|
|
|
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
const context = callArgs[1];
|
|
|
|
// All fields should be in merged config
|
|
expect(context.prompt.config).toMatchObject({
|
|
response_format: { type: 'json_object' },
|
|
temperature: 0.9,
|
|
max_tokens: 500,
|
|
top_p: 0.95,
|
|
custom_field: 'custom_value',
|
|
prompt_field: 'prompt_value', // From prompt
|
|
});
|
|
});
|
|
});
|