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
614 lines
20 KiB
TypeScript
614 lines
20 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import * as comparison from '../src/matchers/comparison';
|
|
import { runDbMigrations } from '../src/migrate';
|
|
import Eval from '../src/models/eval';
|
|
import { evaluate } from '../src/node/evaluate';
|
|
import { ResultFailureReason } from '../src/types';
|
|
import { createEmptyTokenUsage } from '../src/util/tokenUsageUtils';
|
|
import { mockProcessEnv } from './util/utils';
|
|
|
|
import type { ApiProvider } from '../src/types';
|
|
|
|
function createOutputPath(extension = '.jsonl'): string {
|
|
return path.join(os.tmpdir(), `promptfoo-evaluate-${randomUUID()}${extension}`);
|
|
}
|
|
|
|
function readJsonl(outputPath: string): Array<Record<string, any>> {
|
|
return fs
|
|
.readFileSync(outputPath, 'utf8')
|
|
.split(/\r?\n/)
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
describe('programmatic JSONL output', () => {
|
|
const outputPaths: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
await runDbMigrations();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
for (const outputPath of outputPaths.splice(0)) {
|
|
fs.rmSync(outputPath, { force: true });
|
|
}
|
|
});
|
|
|
|
it('includes synthetic timeout rows in the finalized artifact', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'slow-provider',
|
|
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation((_prompt, _context, options) => {
|
|
return new Promise<never>((_resolve, reject) => {
|
|
options?.abortSignal?.addEventListener(
|
|
'abort',
|
|
() => reject(new Error('Provider call aborted')),
|
|
{ once: true },
|
|
);
|
|
});
|
|
}),
|
|
};
|
|
|
|
await evaluate(
|
|
{
|
|
outputPath,
|
|
prompts: ['Test prompt'],
|
|
providers: [provider],
|
|
tests: [{ vars: {} }],
|
|
},
|
|
{ timeoutMs: 10 },
|
|
);
|
|
|
|
expect(readJsonl(outputPath)).toEqual([
|
|
expect.objectContaining({
|
|
error: expect.stringContaining('timed out'),
|
|
failureReason: ResultFailureReason.ERROR,
|
|
success: false,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('rewrites streamed rows with the final max-score state', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'max-score-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'hello world',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Prompt A', 'Prompt B'],
|
|
providers: [provider],
|
|
tests: [
|
|
{
|
|
assert: [{ type: 'contains', value: 'hello' }, { type: 'max-score' }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const results = readJsonl(outputPath).sort((a, b) => a.promptIdx - b.promptIdx);
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0]).toEqual(expect.objectContaining({ success: true }));
|
|
expect(results[1]).toEqual(
|
|
expect.objectContaining({
|
|
failureReason: ResultFailureReason.ASSERT,
|
|
success: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('finalizes failed streamed rows together with later timeout rows', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'partially-slow-provider',
|
|
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation((prompt, _context, options) => {
|
|
if (prompt.includes('fast')) {
|
|
return Promise.resolve({
|
|
output: 'fast response',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
});
|
|
}
|
|
return new Promise<never>((_resolve, reject) => {
|
|
options?.abortSignal?.addEventListener(
|
|
'abort',
|
|
() => reject(new Error('Provider call aborted')),
|
|
{ once: true },
|
|
);
|
|
});
|
|
}),
|
|
};
|
|
const originalAddResult = Eval.prototype.addResult;
|
|
vi.spyOn(Eval.prototype, 'addResult').mockImplementation(async function (this: Eval, result) {
|
|
if (result.promptIdx === 0) {
|
|
throw new Error('simulated save failure');
|
|
}
|
|
return originalAddResult.call(this, result);
|
|
});
|
|
|
|
await evaluate(
|
|
{
|
|
outputPath,
|
|
prompts: ['fast prompt', 'slow prompt'],
|
|
providers: [provider],
|
|
tests: [{ vars: {} }],
|
|
},
|
|
{ timeoutMs: 10 },
|
|
);
|
|
|
|
const results = readJsonl(outputPath).sort((a, b) => a.promptIdx - b.promptIdx);
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0]).toEqual(expect.objectContaining({ success: true }));
|
|
expect(results[1]).toEqual(
|
|
expect.objectContaining({
|
|
error: expect.stringContaining('timed out'),
|
|
failureReason: ResultFailureReason.ERROR,
|
|
success: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('finalizes failed streamed rows together with later max-score updates', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'partially-persisted-max-score-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'hello world',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
const originalAddResult = Eval.prototype.addResult;
|
|
vi.spyOn(Eval.prototype, 'addResult').mockImplementation(async function (this: Eval, result) {
|
|
if (result.promptIdx === 2) {
|
|
throw new Error('simulated save failure');
|
|
}
|
|
return originalAddResult.call(this, result);
|
|
});
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Prompt A', 'Prompt B', 'Prompt C'],
|
|
providers: [provider],
|
|
tests: [
|
|
{
|
|
assert: [{ type: 'contains', value: 'hello' }, { type: 'max-score' }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const results = readJsonl(outputPath).sort((a, b) => a.promptIdx - b.promptIdx);
|
|
expect(results).toHaveLength(3);
|
|
// max-score over three equal outputs keeps only the lowest index as the winner. The row
|
|
// that failed to persist (promptIdx 2) is graded over the full output set and demoted to
|
|
// a loser, not emitted in its stale, pre-comparison success state.
|
|
expect(results[0]).toEqual(expect.objectContaining({ success: true }));
|
|
expect(results[1]).toEqual(expect.objectContaining({ success: false }));
|
|
expect(results[2]).toEqual(expect.objectContaining({ success: false }));
|
|
});
|
|
|
|
it('composes select-best then max-score grading for a row that failed to persist', async () => {
|
|
// Regression: when a test has BOTH select-best and max-score, the failed-to-persist row
|
|
// used to be rehydrated from its stale pre-comparison state for each pass. select-best
|
|
// would demote it, then max-score (which can make it the winner) rehydrated the stale row
|
|
// and overwrote the finalized artifact — erasing the select-best failure and reporting
|
|
// success: true. The reconstructed row must be reused across passes so grading composes.
|
|
const selectBestSpy = vi.spyOn(comparison, 'matchesSelectBest').mockResolvedValue([
|
|
{ pass: true, score: 1, reason: 'Selected as best' },
|
|
{ pass: true, score: 1, reason: 'Selected as best' },
|
|
// The row that failed to persist (promptIdx 2) loses the select-best comparison.
|
|
{ pass: false, score: 0, reason: 'Not selected as best' },
|
|
]);
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
// Only the failed row's output contains "hello", so real max-score makes it the unique
|
|
// winner — without composition it would be re-promoted to success.
|
|
const provider: ApiProvider = {
|
|
id: () => 'compose-comparison-provider',
|
|
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation((prompt) =>
|
|
Promise.resolve({
|
|
output: prompt.includes('Prompt C') ? 'hello world' : 'goodbye',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
),
|
|
};
|
|
const originalAddResult = Eval.prototype.addResult;
|
|
vi.spyOn(Eval.prototype, 'addResult').mockImplementation(async function (this: Eval, result) {
|
|
if (result.promptIdx === 2) {
|
|
throw new Error('simulated save failure');
|
|
}
|
|
return originalAddResult.call(this, result);
|
|
});
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Prompt A', 'Prompt B', 'Prompt C'],
|
|
providers: [provider],
|
|
tests: [
|
|
{
|
|
assert: [
|
|
{ type: 'contains', value: 'hello' },
|
|
{ type: 'select-best', value: 'choose the best one' },
|
|
{ type: 'max-score' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const results = readJsonl(outputPath).sort((a, b) => a.promptIdx - b.promptIdx);
|
|
expect(results).toHaveLength(3);
|
|
// The later max-score pass must not erase the select-best failure for the failed row.
|
|
expect(results[2].success).toBe(false);
|
|
const selectBestComponent = results[2].gradingResult?.componentResults?.find(
|
|
(component: any) => component.assertion?.type === 'select-best',
|
|
);
|
|
expect(selectBestComponent?.pass).toBe(false);
|
|
|
|
selectBestSpy.mockRestore();
|
|
});
|
|
|
|
it('preserves provider and model-graded assertion token usage when finalizing persisted rows', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'token-usage-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'hello world',
|
|
tokenUsage: {
|
|
total: 6,
|
|
prompt: 4,
|
|
completion: 2,
|
|
},
|
|
}),
|
|
};
|
|
const gradingProvider: ApiProvider = {
|
|
id: () => 'grading-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: JSON.stringify({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Test passed',
|
|
}),
|
|
tokenUsage: {
|
|
total: 9,
|
|
prompt: 5,
|
|
completion: 4,
|
|
},
|
|
}),
|
|
};
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Test prompt'],
|
|
providers: [provider],
|
|
tests: [
|
|
{
|
|
assert: [
|
|
{
|
|
type: 'llm-rubric',
|
|
value: 'Output should be valid',
|
|
provider: gradingProvider,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
writeLatestResults: true,
|
|
});
|
|
|
|
expect(readJsonl(outputPath)[0].tokenUsage).toEqual({
|
|
...createEmptyTokenUsage(),
|
|
total: 6,
|
|
prompt: 4,
|
|
completion: 2,
|
|
numRequests: 1,
|
|
assertions: {
|
|
...createEmptyTokenUsage().assertions,
|
|
total: 9,
|
|
prompt: 5,
|
|
completion: 4,
|
|
numRequests: 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('exports non-persisted rows with sensitive HTTP metadata redacted', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'metadata-provider',
|
|
config: {
|
|
apiKey: 'sk-provider-config-should-not-persist',
|
|
},
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: {
|
|
http: {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: {
|
|
authorization: 'model output should stay intact',
|
|
},
|
|
},
|
|
},
|
|
metadata: {
|
|
headers: {
|
|
'x-request-id': 'legacy_req_should_not_persist',
|
|
'api-key': 'legacy-api-key-should-not-persist',
|
|
'x-safe-debug': 'keep-legacy',
|
|
},
|
|
http: {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'set-cookie': 'session=secret',
|
|
'x-request-id': 'req_should_not_persist',
|
|
},
|
|
requestHeaders: {
|
|
authorization: 'Bearer sk-should-not-persist',
|
|
'api-key': 'azure-api-key-should-not-persist',
|
|
'X-API-Key': 'custom-api-key-should-not-persist',
|
|
'x-safe-debug': 'keep-me',
|
|
},
|
|
},
|
|
},
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Test prompt'],
|
|
providers: [provider],
|
|
tests: [{ vars: { topic: 'weather' } }],
|
|
});
|
|
|
|
const [result] = readJsonl(outputPath);
|
|
expect(result.vars).toEqual({ topic: 'weather' });
|
|
// Legacy top-level response metadata headers are redacted (they echo the transport)
|
|
// — including api-key-style names via the request-header matcher — while a
|
|
// non-sensitive header survives.
|
|
expect(result.response.metadata.headers).toEqual({
|
|
'x-request-id': '[REDACTED]',
|
|
'api-key': '[REDACTED]',
|
|
'x-safe-debug': 'keep-legacy',
|
|
});
|
|
expect(result.response.metadata.http).toEqual({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'set-cookie': '[REDACTED]',
|
|
'x-request-id': '[REDACTED]',
|
|
},
|
|
requestHeaders: {
|
|
authorization: '[REDACTED]',
|
|
'api-key': '[REDACTED]',
|
|
'X-API-Key': '[REDACTED]',
|
|
'x-safe-debug': 'keep-me',
|
|
},
|
|
});
|
|
expect(result.provider).toEqual({ id: 'metadata-provider' });
|
|
// A user-controlled `http` key nested in model output must NOT be redacted.
|
|
expect(result.response.output.http.headers.authorization).toBe(
|
|
'model output should stay intact',
|
|
);
|
|
expect(JSON.stringify(result)).not.toContain('session=secret');
|
|
expect(JSON.stringify(result)).not.toContain('req_should_not_persist');
|
|
expect(JSON.stringify(result)).not.toContain('legacy_req_should_not_persist');
|
|
expect(JSON.stringify(result)).not.toContain('legacy-api-key-should-not-persist');
|
|
expect(JSON.stringify(result)).not.toContain('sk-should-not-persist');
|
|
expect(JSON.stringify(result)).not.toContain('azure-api-key-should-not-persist');
|
|
expect(JSON.stringify(result)).not.toContain('custom-api-key-should-not-persist');
|
|
expect(JSON.stringify(result)).not.toContain('sk-provider-config-should-not-persist');
|
|
});
|
|
|
|
it('preserves vars and redacts legacy headers when finalizing persisted rows', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'vars-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'hello world',
|
|
metadata: {
|
|
headers: {
|
|
'set-cookie': ['legacy_persisted_session_should_not_persist'],
|
|
'x-request-id': {
|
|
value: 'legacy_persisted_req_should_not_persist',
|
|
},
|
|
'x-safe-debug': 'keep-legacy',
|
|
},
|
|
},
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Test prompt'],
|
|
providers: [provider],
|
|
tests: [{ vars: { topic: 'weather' } }],
|
|
writeLatestResults: true,
|
|
});
|
|
|
|
const [result] = readJsonl(outputPath);
|
|
expect(result.vars).toEqual({ topic: 'weather' });
|
|
expect(result.response.metadata.headers).toEqual({
|
|
'set-cookie': '[REDACTED]',
|
|
'x-request-id': '[REDACTED]',
|
|
'x-safe-debug': 'keep-legacy',
|
|
});
|
|
// The result-level metadata.headers echoes the transport, so it is redacted too.
|
|
expect(result.metadata.headers).toEqual({
|
|
'set-cookie': '[REDACTED]',
|
|
'x-request-id': '[REDACTED]',
|
|
'x-safe-debug': 'keep-legacy',
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain('legacy_persisted_session_should_not_persist');
|
|
expect(JSON.stringify(result)).not.toContain('legacy_persisted_req_should_not_persist');
|
|
});
|
|
|
|
it('preserves arbitrary test metadata headers when finalizing persisted rows', async () => {
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'metadata-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'hello world',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Test prompt'],
|
|
providers: [provider],
|
|
tests: [
|
|
{
|
|
metadata: {
|
|
headers: {
|
|
'x-request-id': 'user-defined-reporting-id',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
writeLatestResults: true,
|
|
});
|
|
|
|
const [result] = readJsonl(outputPath);
|
|
// No transport provenance for these headers, so the provenance guard must preserve them.
|
|
expect(result.metadata.headers).toEqual({
|
|
'x-request-id': 'user-defined-reporting-id',
|
|
});
|
|
});
|
|
|
|
it('preserves sanitized streamed rows for uppercase JSONL after persistence fails', async () => {
|
|
const outputPath = createOutputPath('.JSONL');
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'metadata-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'hello world',
|
|
metadata: {
|
|
http: {
|
|
headers: {
|
|
'set-cookie': 'session=secret',
|
|
},
|
|
requestHeaders: {
|
|
authorization: 'Bearer sk-should-not-persist',
|
|
},
|
|
},
|
|
},
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
vi.spyOn(Eval.prototype, 'addResult').mockRejectedValue(new Error('simulated save failure'));
|
|
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Test prompt'],
|
|
providers: [provider],
|
|
tests: [{ vars: { topic: 'weather' } }],
|
|
});
|
|
|
|
const [result] = readJsonl(outputPath);
|
|
expect(result.vars).toEqual({ topic: 'weather' });
|
|
expect(result.response.metadata.http.headers['set-cookie']).toBe('[REDACTED]');
|
|
expect(result.response.metadata.http.requestHeaders.authorization).toBe('[REDACTED]');
|
|
expect(JSON.stringify(result)).not.toContain('session=secret');
|
|
expect(JSON.stringify(result)).not.toContain('sk-should-not-persist');
|
|
});
|
|
|
|
it('applies strip projections to recovered streamed rows after persistence fails', async () => {
|
|
const restoreEnv = mockProcessEnv({
|
|
PROMPTFOO_STRIP_METADATA: 'true',
|
|
PROMPTFOO_STRIP_RESPONSE_OUTPUT: 'true',
|
|
PROMPTFOO_STRIP_TEST_VARS: 'true',
|
|
});
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'stripped-recovery-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'response-output-secret',
|
|
metadata: {
|
|
debug: 'response-metadata-secret',
|
|
},
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
vi.spyOn(Eval.prototype, 'addResult').mockRejectedValue(new Error('simulated save failure'));
|
|
|
|
try {
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Test prompt'],
|
|
providers: [provider],
|
|
tests: [
|
|
{
|
|
vars: { customerEmail: 'vars-secret@example.com' },
|
|
metadata: { debug: 'testcase-metadata-secret' },
|
|
},
|
|
],
|
|
});
|
|
|
|
const [result] = readJsonl(outputPath);
|
|
expect(result.response).toEqual({
|
|
output: '[output stripped]',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
});
|
|
expect(result.vars).toEqual({});
|
|
expect(result.testCase.vars).toBeUndefined();
|
|
expect(result.testCase.metadata).toBeUndefined();
|
|
expect(result.metadata).toEqual({});
|
|
expect(JSON.stringify(result)).not.toContain('response-output-secret');
|
|
expect(JSON.stringify(result)).not.toContain('response-metadata-secret');
|
|
expect(JSON.stringify(result)).not.toContain('vars-secret@example.com');
|
|
expect(JSON.stringify(result)).not.toContain('testcase-metadata-secret');
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('applies prompt-text and grading-result strip projections to finalized JSONL', async () => {
|
|
const restoreEnv = mockProcessEnv({
|
|
PROMPTFOO_STRIP_PROMPT_TEXT: 'true',
|
|
PROMPTFOO_STRIP_GRADING_RESULT: 'true',
|
|
});
|
|
const outputPath = createOutputPath();
|
|
outputPaths.push(outputPath);
|
|
const provider: ApiProvider = {
|
|
id: () => 'strip-prompt-grading-provider',
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'hello world',
|
|
tokenUsage: createEmptyTokenUsage(),
|
|
}),
|
|
};
|
|
|
|
try {
|
|
await evaluate({
|
|
outputPath,
|
|
prompts: ['Prompt {{topic}}'],
|
|
providers: [provider],
|
|
tests: [{ vars: { topic: 'alpha' }, assert: [{ type: 'contains', value: 'hello' }] }],
|
|
});
|
|
|
|
const [result] = readJsonl(outputPath);
|
|
expect(result.prompt.raw).toBe('[prompt stripped]');
|
|
expect(result.gradingResult).toBeNull();
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
});
|