Files
promptfoo--promptfoo/test/matchers/llm-rubric.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

2740 lines
86 KiB
TypeScript

import path from 'path';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadFromJavaScriptFile } from '../../src/assertions/utils';
import cliState from '../../src/cliState';
import { importModule } from '../../src/esm';
import { matchesLlmRubric } from '../../src/matchers/llmGrading';
import { renderLlmRubricPrompt } from '../../src/matchers/rubric';
import { OpenAiChatCompletionProvider } from '../../src/providers/openai/chat';
import { DefaultGradingProvider } from '../../src/providers/openai/defaults';
import * as remoteGrading from '../../src/remoteGrading';
import { createMockProvider, createProviderResponse } from '../factories/provider';
import { mockProcessEnv, TestGrader } from '../util/utils';
import type { Assertion, GradingConfig } from '../../src/types/index';
vi.mock('../../src/esm', () => ({
importModule: vi.fn(),
}));
vi.mock('../../src/cliState');
vi.mock('../../src/remoteGrading', () => ({
doRemoteGrading: vi.fn(),
}));
vi.mock('../../src/redteam/remoteGeneration', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/redteam/remoteGeneration')>();
return {
...actual,
shouldGenerateRemote: vi.fn().mockReturnValue(false),
};
});
// Create mock functions that can be configured in tests - use vi.hoisted for mock factory access
const { mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
}));
const mockReadFile = vi.hoisted(() =>
vi.fn((filePath: string, ...args: unknown[]) => {
if (!mockExistsSync(filePath)) {
throw Object.assign(new Error(`ENOENT: no such file or directory, open '${filePath}'`), {
code: 'ENOENT',
});
}
return mockReadFileSync(filePath, ...args);
}),
);
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
};
});
vi.mock('fs/promises', () => ({
default: {
readFile: mockReadFile,
},
readFile: mockReadFile,
}));
const Grader = new TestGrader();
describe('matchesLlmRubric', () => {
const mockFilePath = path.join('path', 'to', 'external', 'rubric.txt');
const mockFileContent = 'This is an external rubric prompt';
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(mockFileContent);
cliState.config = {};
cliState.selectedProviderConfigs = undefined;
vi.mocked(remoteGrading.doRemoteGrading).mockReset();
vi.mocked(remoteGrading.doRemoteGrading).mockResolvedValue({
pass: true,
score: 1,
reason: 'Remote grading passed',
});
vi.spyOn(DefaultGradingProvider, 'callApi').mockReset();
vi.spyOn(DefaultGradingProvider, 'callApi').mockResolvedValue({
output: JSON.stringify({ pass: true, score: 1, reason: 'Test passed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
});
});
afterEach(() => {
cliState.selectedProviderConfigs = undefined;
});
it('should pass when the grading provider returns a passing result', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: Grader,
};
vi.spyOn(Grader, 'callApi').mockResolvedValue({
output: JSON.stringify({ pass: true, reason: 'Test grading output' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
});
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual(
expect.objectContaining({
pass: true,
reason: 'Test grading output',
score: 1,
tokensUsed: {
total: expect.any(Number),
prompt: expect.any(Number),
completion: expect.any(Number),
cached: expect.any(Number),
completionDetails: expect.any(Object),
numRequests: 0,
},
}),
);
});
it('should handle when provider returns direct object output instead of string', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: { pass: true, score: 0.85, reason: 'Direct object output' },
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual(
expect.objectContaining({
pass: true,
score: 0.85,
reason: 'Direct object output',
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
},
numRequests: 0,
},
}),
);
});
it('should preserve provider completion details when grader JSON omits them', async () => {
const completionDetails = {
reasoning: 3,
acceptedPrediction: 2,
rejectedPrediction: 1,
};
const result = await matchesLlmRubric('Expected output', 'Sample output', {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
tokenUsage: {
total: 10,
prompt: 5,
completion: 5,
completionDetails,
},
},
}),
});
expect(result.tokensUsed?.completionDetails).toEqual(completionDetails);
});
it('should merge provider metadata into the grading result metadata', async () => {
const result = await matchesLlmRubric('Expected output', 'Sample output', {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
metadata: {
uploadId: 'upload-123',
trace: { id: 'trace-456' },
},
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
});
expect(result.metadata).toEqual({
uploadId: 'upload-123',
trace: { id: 'trace-456' },
renderedGradingPrompt: 'Grading prompt',
});
});
it('should preserve renderedGradingPrompt when provider metadata uses the same key', async () => {
const result = await matchesLlmRubric('Expected output', 'Sample output', {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
metadata: {
renderedGradingPrompt: 'spoofed prompt',
uploadId: 'upload-123',
},
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
});
expect(result.metadata).toEqual({
uploadId: 'upload-123',
renderedGradingPrompt: 'Grading prompt',
});
});
it('should ignore array provider metadata', async () => {
const result = await matchesLlmRubric('Expected output', 'Sample output', {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
metadata: ['ignored'] as any,
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
});
expect(result.metadata).toEqual({
renderedGradingPrompt: 'Grading prompt',
});
});
it('should sanitize circular provider metadata before attaching it to the grading result', async () => {
const responseMetadata: Record<string, any> = {
uploadId: 'upload-123',
trace: { id: 'trace-456' },
};
responseMetadata.self = responseMetadata;
const result = await matchesLlmRubric('Expected output', 'Sample output', {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
metadata: responseMetadata,
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
});
expect(result.metadata).toEqual({
uploadId: 'upload-123',
trace: { id: 'trace-456' },
renderedGradingPrompt: 'Grading prompt',
});
expect(result.metadata).not.toHaveProperty('self');
});
it('should attach image outputs to the grading provider prompt', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
const result = await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'Grade this output: Generated image' },
{
type: 'text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ type: 'image_url', image_url: { url: 'data:image/png;base64,abc123' } },
],
},
]);
expect(provider.callApi).toHaveBeenCalledWith(
prompt,
expect.objectContaining({
prompt: expect.objectContaining({
label: 'llm-rubric',
raw: prompt,
}),
}),
);
expect(result.metadata).toEqual({
renderedGradingPrompt: 'Grade this output: Generated image',
renderedGradingPromptImages: 1,
});
});
it('should replace image data URI text output when attaching image outputs to the grading provider prompt', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
const imageOutput = 'data:image/png;base64,abc123';
const result = await matchesLlmRubric(
'Does the image match?',
imageOutput,
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: imageOutput,
images: [{ data: imageOutput, mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)[0].content[0]).toEqual({
type: 'text',
text: 'Grade this output: [Image output attached. Inspect the attached image directly for visual grading.]',
});
expect(result.metadata).toEqual({
renderedGradingPrompt:
'Grade this output: [Image output attached. Inspect the attached image directly for visual grading.]',
renderedGradingPromptImages: 1,
});
});
it('should preserve JSON chat rubric prompts when attaching images', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: JSON.stringify([
{ role: 'system', content: 'Return JSON.' },
{ role: 'user', content: 'Grade this output: {{ output }}' },
]),
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'abc123', mimeType: 'image/webp' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{ role: 'system', content: 'Return JSON.' },
{
role: 'user',
content: [
{ type: 'text', text: 'Grade this output: Generated image' },
{
type: 'text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ type: 'image_url', image_url: { url: 'data:image/webp;base64,abc123' } },
],
},
]);
});
it('should preserve YAML chat rubric prompts when attaching images', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: [
'- role: system',
' content: Return JSON.',
'- role: user',
' content: "Grade this output: {{ output }}"',
].join('\n'),
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'abc123', mimeType: 'image/webp' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{ role: 'system', content: 'Return JSON.' },
{
role: 'user',
content: [
{ type: 'text', text: 'Grade this output: Generated image' },
{
type: 'text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ type: 'image_url', image_url: { url: 'data:image/webp;base64,abc123' } },
],
},
]);
});
it('should use Anthropic image parts for Anthropic grading providers', async () => {
const provider = createMockProvider({
id: 'anthropic:messages:claude-3-5-sonnet-latest',
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)[0].content).toContainEqual({
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc123',
},
});
});
it.each([
'cloudflare-ai-gateway:anthropic:messages:claude-3-5-sonnet-latest',
'cloudflare-gateway:anthropic:claude-sonnet-4-20250514',
'bedrock:us.anthropic.claude-sonnet-4-5-20250929-v1:0',
'bedrock:converse:anthropic.claude-3-5-sonnet-20241022-v2:0',
'vertex:claude-3-5-sonnet-v2@20241022',
])('should use Anthropic image parts for wrapped Anthropic grading provider %s', async (id) => {
const provider = createMockProvider({
id,
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)[0].content).toContainEqual({
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc123',
},
});
});
it.each([
'google:gemini-2.5-pro',
'vertex:gemini-2.5-pro',
])('should use Google inlineData image parts for Gemini grading provider %s', async (id) => {
const provider = createMockProvider({
id,
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'Grade this output: Generated image' },
{
type: 'text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ inlineData: { mimeType: 'image/png', data: 'abc123' } },
],
},
]);
});
it('should convert existing chat content parts when using Gemini grading providers', async () => {
const provider = createMockProvider({
id: 'google:gemini-2.5-pro',
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: JSON.stringify([
{ role: 'system', content: 'Return JSON.' },
{
role: 'user',
content: [
{ type: 'input_text', text: 'Grade this output: {{ output }}' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,existing' } },
],
},
]),
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{ role: 'system', content: 'Return JSON.' },
{
role: 'user',
content: [
{ type: 'text', text: 'Grade this output: Generated image' },
{ inlineData: { mimeType: 'image/png', data: 'existing' } },
{
type: 'text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ inlineData: { mimeType: 'image/png', data: 'abc123' } },
],
},
]);
});
it('should normalize supported existing image content shapes for Gemini grading providers', async () => {
const provider = createMockProvider({
id: 'google:gemini-2.5-pro',
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: JSON.stringify([
{
role: 'user',
content: [
'Literal chat content',
{ type: 'text', text: 'Grade this output: {{ output }}' },
{ type: 'input_image', image_url: 'data:image/jpeg;base64,input123' },
{ type: 'input_image', image_url: 'not-a-data-uri' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/webp',
data: 'anthro123',
},
},
{ inlineData: { mimeType: 'image/gif', data: 'inline123' } },
{ inline_data: { mime_type: 'image/png', data: 'snake123' } },
{ unexpected: 'value' },
null,
],
},
]),
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'Literal chat content' },
{ type: 'text', text: 'Grade this output: Generated image' },
{ inlineData: { mimeType: 'image/jpeg', data: 'input123' } },
{
type: 'text',
text: '{"type":"input_image","image_url":"not-a-data-uri"}',
},
{ inlineData: { mimeType: 'image/webp', data: 'anthro123' } },
{ inlineData: { mimeType: 'image/gif', data: 'inline123' } },
{ inlineData: { mimeType: 'image/png', data: 'snake123' } },
{ type: 'text', text: '{"unexpected":"value"}' },
{ type: 'text', text: 'null' },
{
type: 'text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ inlineData: { mimeType: 'image/png', data: 'abc123' } },
],
},
]);
});
it('should use Responses image parts for Responses grading providers', async () => {
const provider = createMockProvider({
id: 'openai:responses:gpt-5.4',
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{
role: 'user',
content: [
{ type: 'input_text', text: 'Grade this output: Generated image' },
{
type: 'input_text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ type: 'input_image', image_url: 'data:image/png;base64,abc123' },
],
},
]);
});
it.each([
['azure:my-deployment', 'AzureResponsesProvider'],
['bedrock:openai.gpt-5.5', 'BedrockOpenAiResponsesProvider'],
['openai:gpt-5.5', 'OpenAiResponsesProvider'],
])('should use Responses image parts for %s when provider class is %s', async (id, providerClassName) => {
const provider = createMockProvider({
id,
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
Object.defineProperty(provider, 'constructor', {
value: { name: providerClassName },
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)[0].content).toContainEqual({
type: 'input_image',
image_url: 'data:image/png;base64,abc123',
});
});
it('should not use Responses image parts for Bedrock completion providers', async () => {
const provider = createMockProvider({
id: 'bedrock:completion:openai.gpt-oss-120b-1:0',
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
Object.defineProperty(provider, 'constructor', {
value: { name: 'AwsBedrockCompletionProvider' },
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)[0].content).toContainEqual({
type: 'image_url',
image_url: { url: 'data:image/png;base64,abc123' },
});
});
it('should convert existing chat content parts when using Responses grading providers', async () => {
const provider = createMockProvider({
id: 'openai:responses:gpt-5.4',
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: JSON.stringify([
{ role: 'system', content: 'Return JSON.' },
{
role: 'user',
content: [
{ type: 'text', text: 'Grade this output: {{ output }}' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,existing' } },
],
},
]),
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{ role: 'system', content: 'Return JSON.' },
{
role: 'user',
content: [
{ type: 'input_text', text: 'Grade this output: Generated image' },
{ type: 'input_image', image_url: 'data:image/png;base64,existing' },
{
type: 'input_text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ type: 'input_image', image_url: 'data:image/png;base64,abc123' },
],
},
]);
});
it('should append a user message when a JSON chat rubric has no user message', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: JSON.stringify([{ role: 'system', content: 'Return JSON.' }]),
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual([
{ role: 'system', content: 'Return JSON.' },
{
role: 'user',
content: [
{
type: 'text',
text: 'The evaluated output includes the attached image(s). Treat the attached image(s) as primary evidence in <Output>. Inspect the visual content directly, and do not infer visual traits, demographics, safety issues, or rubric failures from the user prompt or from any base64/data URI text.',
},
{ type: 'image_url', image_url: { url: 'data:image/png;base64,abc123' } },
],
},
]);
});
it('should leave the grading prompt unchanged when image outputs cannot be materialized', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'text only ok' }),
},
});
const result = await matchesLlmRubric(
'Does the output match?',
'Generated output',
{
rubricPrompt: 'Grade this output: {{ output }}',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated output',
images: [{}],
},
},
);
expect(provider.callApi).toHaveBeenCalledWith(
'Grade this output: Generated output',
expect.any(Object),
);
expect(result.metadata).toEqual({
renderedGradingPrompt: 'Grade this output: Generated output',
});
});
it('should reject remote image URLs before grading', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await expect(
matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'https://example.com/image.png', mimeType: 'image/png' }],
},
},
),
).rejects.toThrow(
'Remote image URLs are not supported for multimodal grading. Provide local image output as a data URI or raw base64 string instead.',
);
expect(provider.callApi).not.toHaveBeenCalled();
});
it('should reject blob-backed image outputs before grading', async () => {
const hash = 'a'.repeat(64);
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await expect(
matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [
{
blobRef: {
uri: `promptfoo://blob/${hash}`,
hash,
mimeType: 'image/png',
sizeBytes: 11,
provider: 'filesystem',
},
},
],
},
},
),
).rejects.toThrow(
'Blob-backed image outputs are not supported for multimodal grading yet. Configure the image provider to return base64 or data URI image output.',
);
expect(provider.callApi).not.toHaveBeenCalled();
});
it('should normalize whitespace-padded base64 image output before grading', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: 'data:image/png;base64, a b c 1 2 3 ', mimeType: 'image/png' }],
},
},
);
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)[0].content).toContainEqual({
type: 'image_url',
image_url: { url: 'data:image/png;base64,abc123' },
});
});
it('should reject whitespace-only image output data before grading', async () => {
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
await expect(
matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: ' ', mimeType: 'image/png' }],
},
},
),
).rejects.toThrow('Image output data must contain non-empty base64 image data.');
expect(provider.callApi).not.toHaveBeenCalled();
});
it('should reject raw image data that exceeds the raw character cap before grading', async () => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_GRADING_IMAGE_MAX_RAW_CHARS: '20' });
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
try {
await expect(
matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: `abc123${' '.repeat(20)}`, mimeType: 'image/png' }],
},
},
),
).rejects.toThrow('Image output raw data exceeds multimodal grading size limit');
} finally {
restoreEnv();
}
expect(provider.callApi).not.toHaveBeenCalled();
});
it('should reject too many image outputs before grading', async () => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_GRADING_MAX_IMAGES: '1' });
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
try {
await expect(
matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [
{ data: 'abc123', mimeType: 'image/png' },
{ data: 'def456', mimeType: 'image/png' },
],
},
},
),
).rejects.toThrow('Too many images for multimodal grading: received 2, maximum is 1.');
} finally {
restoreEnv();
}
expect(provider.callApi).not.toHaveBeenCalled();
});
it('should reject oversized inline image outputs before grading', async () => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_GRADING_IMAGE_MAX_BYTES: '4' });
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
try {
await expect(
matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [{ data: Buffer.alloc(5).toString('base64'), mimeType: 'image/png' }],
},
},
),
).rejects.toThrow(
'Image output exceeds multimodal grading size limit: 5 bytes, maximum is 4.',
);
} finally {
restoreEnv();
}
expect(provider.callApi).not.toHaveBeenCalled();
});
it('should reject inline image outputs that exceed the total size limit before grading', async () => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_GRADING_IMAGE_MAX_BYTES: '10',
PROMPTFOO_GRADING_IMAGE_MAX_TOTAL_BYTES: '9',
});
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'image ok' }),
},
});
try {
await expect(
matchesLlmRubric(
'Does the image match?',
'Generated image',
{
rubricPrompt: 'Grade this output',
provider,
},
{},
undefined,
{
providerResponse: {
output: 'Generated image',
images: [
{ data: Buffer.alloc(5).toString('base64'), mimeType: 'image/png' },
{ data: Buffer.alloc(5).toString('base64'), mimeType: 'image/png' },
],
},
},
),
).rejects.toThrow(
'Image outputs exceed multimodal grading total size limit: 10 bytes, maximum is 9.',
);
} finally {
restoreEnv();
}
expect(provider.callApi).not.toHaveBeenCalled();
});
it('should render rubric when provided as an object', async () => {
const rubric = { prompt: 'Describe the image' };
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grade: {{ rubric }}',
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
tokenUsage: { total: 1, prompt: 1, completion: 1 },
},
}),
};
await matchesLlmRubric(rubric, output, options);
expect(options.provider.callApi).toHaveBeenCalledWith(
expect.stringContaining(JSON.stringify(rubric)),
expect.objectContaining({
prompt: expect.objectContaining({
label: 'llm-rubric',
raw: expect.stringContaining(JSON.stringify(rubric)),
}),
vars: expect.objectContaining({
output: 'Sample output',
rubric: rubric,
}),
}),
);
});
it('should fail when output is neither string nor object', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: 42, // Numeric output
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason:
'llm-rubric produced malformed response - output must be string or object. Output: 42',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
});
});
it('should fail when output is null', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: null,
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason: 'No output',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
});
});
it('should fail when output is an array', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: [{ pass: false, score: 0, reason: 'bad' }],
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason:
'llm-rubric produced malformed response - output must be string or object. Output: [{"pass":false,"score":0,"reason":"bad"}]',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
});
});
it('should handle string output with invalid JSON format', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: '{ "pass": true, "reason": "Invalid JSON missing closing brace',
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason: expect.stringContaining('Could not extract JSON from llm-rubric response'),
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
});
});
it('should fail when string output contains no JSON objects', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: 'This is a valid text response but contains no JSON objects',
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason: 'Could not extract JSON from llm-rubric response',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
});
});
it('should fail when string output contains only empty JSON array', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: 'Here is the result: []',
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason: 'Could not extract JSON from llm-rubric response',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
},
});
});
it('should fail when string contains only null', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: 'Result: null',
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
// Since extractJsonObjects only looks for objects starting with {, this returns no objects
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason: 'Could not extract JSON from llm-rubric response',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
},
});
});
it('should fail when string contains only a JSON string primitive', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: '"just a string"',
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason: 'Could not extract JSON from llm-rubric response',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
},
});
});
it('should fail when string contains only a number', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: 'Result: 123',
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual({
pass: false,
score: 0,
reason: 'Could not extract JSON from llm-rubric response',
metadata: { graderError: true },
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
},
});
});
it('should fail when the grading provider returns a failing result', async () => {
const expected = 'Expected output';
const output = 'Different output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: Grader,
};
vi.spyOn(Grader, 'callApi').mockResolvedValueOnce({
output: JSON.stringify({ pass: false, reason: 'Grading failed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
});
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual(
expect.objectContaining({
pass: false,
reason: 'Grading failed',
score: 0,
tokensUsed: {
total: expect.any(Number),
prompt: expect.any(Number),
completion: expect.any(Number),
cached: expect.any(Number),
completionDetails: expect.any(Object),
numRequests: 0,
},
}),
);
});
it('should throw error when throwOnError is true and provider returns an error', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
error: 'Provider error',
output: null,
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
// With throwOnError: true - should throw
await expect(
matchesLlmRubric(rubric, llmOutput, grading, {}, undefined, { throwOnError: true }),
).rejects.toThrow('Provider error');
});
it('should throw error when throwOnError is true and provider returns no result', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: createMockProvider({
response: {
output: null,
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
// With throwOnError: true - should throw
await expect(
matchesLlmRubric(rubric, llmOutput, grading, {}, undefined, { throwOnError: true }),
).rejects.toThrow('No output');
});
it('should use the overridden llm rubric grading config', async () => {
const expected = 'Expected output';
const output = 'Sample output';
const options: GradingConfig = {
rubricPrompt: 'Grading prompt',
provider: {
id: 'openai:gpt-4o-mini',
config: {
apiKey: 'abc123',
temperature: 3.1415926,
},
},
};
const mockCallApi = vi.spyOn(OpenAiChatCompletionProvider.prototype, 'callApi');
mockCallApi.mockImplementation(function (this: OpenAiChatCompletionProvider) {
expect(this.config.temperature).toBe(3.1415926);
expect(this.getApiKey()).toBe('abc123');
return Promise.resolve({
output: JSON.stringify({ pass: true, reason: 'Grading passed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
});
});
await expect(matchesLlmRubric(expected, output, options)).resolves.toEqual(
expect.objectContaining({
reason: 'Grading passed',
pass: true,
score: 1,
tokensUsed: {
total: expect.any(Number),
prompt: expect.any(Number),
completion: expect.any(Number),
cached: expect.any(Number),
completionDetails: expect.any(Object),
numRequests: 0,
},
}),
);
expect(mockCallApi).toHaveBeenCalledWith(
'Grading prompt',
expect.objectContaining({
prompt: expect.objectContaining({
label: 'llm-rubric',
raw: 'Grading prompt',
}),
vars: expect.objectContaining({
output: 'Sample output',
rubric: 'Expected output',
}),
}),
);
mockCallApi.mockRestore();
});
it('should use provided score threshold if llm does not return pass', async () => {
const rubricPrompt = 'Rubric prompt';
const llmOutput = 'Sample output';
const assertion: Assertion = {
type: 'llm-rubric',
value: rubricPrompt,
threshold: 0.5,
};
const lowScoreResponse = { score: 0.25, reason: 'Low score' };
const lowScoreProvider = createMockProvider({
response: createProviderResponse({ output: JSON.stringify(lowScoreResponse) }),
});
await expect(
matchesLlmRubric(
rubricPrompt,
llmOutput,
{ rubricPrompt, provider: lowScoreProvider },
{},
assertion,
),
).resolves.toEqual(expect.objectContaining({ assertion, pass: false, ...lowScoreResponse }));
const highScoreResponse = { score: 0.75, reason: 'High score' };
const highScoreProvider = createMockProvider({
response: createProviderResponse({ output: JSON.stringify(highScoreResponse) }),
});
await expect(
matchesLlmRubric(
rubricPrompt,
llmOutput,
{ rubricPrompt, provider: highScoreProvider },
{},
assertion,
),
).resolves.toEqual(expect.objectContaining({ assertion, pass: true, ...highScoreResponse }));
});
it('should ignore the score threshold if llm returns pass', async () => {
const rubricPrompt = 'Rubric prompt';
const output = 'Sample output';
const assertion: Assertion = {
type: 'llm-rubric',
value: rubricPrompt,
threshold: 0.1,
};
const lowScoreResult = { score: 0.25, reason: 'Low score but pass', pass: true };
const lowScoreOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(lowScoreResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, lowScoreOptions, {}, assertion),
).resolves.toEqual(expect.objectContaining({ assertion, ...lowScoreResult }));
});
it('should respect both threshold and explicit pass/fail when both are present', async () => {
const rubricPrompt = 'Rubric prompt';
const output = 'Sample output';
const assertion: Assertion = {
type: 'llm-rubric',
value: rubricPrompt,
threshold: 0.8,
};
// Case 1: Pass is true but score is below threshold
const failingResult = { score: 0.7, reason: 'Score below threshold', pass: true };
const failingOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(failingResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, failingOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
score: 0.7,
pass: false,
reason: 'Score below threshold',
}),
);
// Case 2: Pass is false but score is above threshold
const passingResult = {
score: 0.9,
reason: 'Score above threshold but explicit fail',
pass: false,
};
const passingOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(passingResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, passingOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
score: 0.9,
pass: false,
reason: 'Score above threshold but explicit fail',
}),
);
});
it('should handle edge cases around threshold value', async () => {
const rubricPrompt = 'Rubric prompt';
const output = 'Sample output';
const assertion: Assertion = {
type: 'llm-rubric',
value: rubricPrompt,
threshold: 0.8,
};
// Exactly at threshold should pass
const exactThresholdResult = { score: 0.8, reason: 'Exactly at threshold' };
const exactOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(exactThresholdResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, exactOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
score: 0.8,
pass: true,
reason: 'Exactly at threshold',
}),
);
// Just below threshold should fail
const justBelowResult = { score: 0.799, reason: 'Just below threshold' };
const belowOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(justBelowResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, belowOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
score: 0.799,
pass: false,
reason: 'Just below threshold',
}),
);
});
it('should handle missing or invalid scores when threshold is present', async () => {
const rubricPrompt = 'Rubric prompt';
const output = 'Sample output';
const assertion: Assertion = {
type: 'llm-rubric',
value: rubricPrompt,
threshold: 0.8,
};
// Missing score should default to pass value
const missingScoreResult = { pass: true, reason: 'No score provided' };
const missingScoreOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(missingScoreResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, missingScoreOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
score: 1.0,
pass: true,
reason: 'No score provided',
}),
);
// Invalid score type should be handled gracefully
const invalidScoreResult = { score: 'high', reason: 'Invalid score type', pass: true };
const invalidScoreOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(invalidScoreResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, invalidScoreOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
score: 1.0,
pass: true,
reason: 'Invalid score type',
}),
);
});
it('should handle string scores', async () => {
const rubricPrompt = 'Rubric prompt';
const output = 'Sample output';
const assertion: Assertion = {
type: 'llm-rubric',
value: rubricPrompt,
threshold: 0.8,
};
const stringScoreResult = { score: '0.9', reason: 'String score' };
const stringScoreOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(stringScoreResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, stringScoreOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
score: 0.9,
pass: true,
reason: 'String score',
}),
);
});
it('should handle string pass values', async () => {
const rubricPrompt = 'Rubric prompt';
const output = 'Sample output';
const assertion: Assertion = {
type: 'llm-rubric',
value: rubricPrompt,
threshold: 0.8,
};
const stringPassResult = { reason: 'String pass', pass: 'true' };
const stringPassOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(stringPassResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, stringPassOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
pass: true,
reason: 'String pass',
}),
);
const stringFailResult = { reason: 'String fail', pass: 'false' };
const stringFailOptions: GradingConfig = {
rubricPrompt,
provider: createMockProvider({
response: {
output: JSON.stringify(stringFailResult),
},
}),
};
await expect(
matchesLlmRubric(rubricPrompt, output, stringFailOptions, {}, assertion),
).resolves.toEqual(
expect.objectContaining({
assertion,
pass: false,
reason: 'String fail',
}),
);
});
it('should load rubric prompt from external file when specified', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
rubricPrompt: `file://${mockFilePath}`,
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Test passed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
const result = await matchesLlmRubric(rubric, llmOutput, grading);
expect(mockExistsSync).toHaveBeenCalledWith(
expect.stringContaining(path.join('path', 'to', 'external', 'rubric.txt')),
);
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringContaining(path.join('path', 'to', 'external', 'rubric.txt')),
'utf8',
);
expect(grading.provider.callApi).toHaveBeenCalledWith(
expect.stringContaining(mockFileContent),
expect.objectContaining({
prompt: expect.objectContaining({
label: 'llm-rubric',
raw: expect.stringContaining(mockFileContent),
}),
vars: expect.objectContaining({
output: 'Test output',
rubric: 'Test rubric',
}),
}),
);
expect(result).toEqual(
expect.objectContaining({
pass: true,
score: 1,
reason: 'Test passed',
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
}),
);
});
it('should load rubric prompt from JSON file with Nunjucks templates', async () => {
// This test verifies the fix for issue #2961:
// JSON files with Nunjucks templates should be loaded as raw text first,
// allowing template rendering before JSON parsing.
const mockJsonFilePath = path.join('path', 'to', 'rubric.json');
const mockJsonContent = `[
{%- set system_prompt -%}
Evaluate the response
{%- endset -%}
{ "role": "system", "content": {{ system_prompt | dump }} },
{ "role": "user", "content": "Output: {{ output }}" }
]`;
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(mockJsonContent);
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
rubricPrompt: `file://${mockJsonFilePath}`,
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Test passed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
const result = await matchesLlmRubric(rubric, llmOutput, grading);
expect(mockExistsSync).toHaveBeenCalledWith(
expect.stringContaining(path.join('path', 'to', 'rubric.json')),
);
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringContaining(path.join('path', 'to', 'rubric.json')),
'utf8',
);
// Verify the template was rendered - the Nunjucks set block should be processed
expect(grading.provider.callApi).toHaveBeenCalledWith(
expect.stringContaining('Evaluate the response'),
expect.anything(),
);
expect(grading.provider.callApi).toHaveBeenCalledWith(
expect.stringContaining('Test output'),
expect.anything(),
);
expect(result).toEqual(
expect.objectContaining({
pass: true,
score: 1,
reason: 'Test passed',
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
}),
);
});
it('should load rubric prompt from YAML file with Nunjucks templates', async () => {
const mockYamlFilePath = path.join('path', 'to', 'rubric.yaml');
const mockYamlContent = `{%- set system_prompt -%}
Evaluate the response
{%- endset -%}
- role: system
content: {{ system_prompt }}
- role: user
content: "Output: {{ output }}"`;
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(mockYamlContent);
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
rubricPrompt: `file://${mockYamlFilePath}`,
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Test passed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
const result = await matchesLlmRubric(rubric, llmOutput, grading);
expect(mockExistsSync).toHaveBeenCalledWith(
expect.stringContaining(path.join('path', 'to', 'rubric.yaml')),
);
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringContaining(path.join('path', 'to', 'rubric.yaml')),
'utf8',
);
// Verify the template was rendered
expect(grading.provider.callApi).toHaveBeenCalledWith(
expect.stringContaining('Evaluate the response'),
expect.anything(),
);
expect(result).toEqual(
expect.objectContaining({
pass: true,
score: 1,
reason: 'Test passed',
tokensUsed: {
total: 10,
prompt: 5,
completion: 5,
cached: 0,
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
numRequests: 0,
},
}),
);
});
it('should load rubric prompt from js file when specified', async () => {
const filePath = path.join('path', 'to', 'external', 'file.js');
const mockImportModule = vi.mocked(importModule);
const mockFunction = vi.fn(() => 'Do this: {{ rubric }}');
mockImportModule.mockResolvedValue(mockFunction);
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
rubricPrompt: `file://${filePath}`,
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Test passed' }),
},
}),
};
const result = await matchesLlmRubric(rubric, llmOutput, grading);
await expect(loadFromJavaScriptFile(filePath, undefined, [])).resolves.toBe(
'Do this: {{ rubric }}',
);
expect(grading.provider.callApi).toHaveBeenCalledWith(
expect.stringContaining('Do this: Test rubric'),
expect.objectContaining({
prompt: expect.objectContaining({
label: 'llm-rubric',
raw: expect.stringContaining('Do this: Test rubric'),
}),
vars: expect.objectContaining({
output: 'Test output',
rubric: 'Test rubric',
}),
}),
);
expect(mockImportModule).toHaveBeenCalledWith(filePath, undefined);
expect(result).toEqual(
expect.objectContaining({ pass: true, score: 1, reason: 'Test passed' }),
);
});
it('should throw an error when the external file is not found', async () => {
mockExistsSync.mockReturnValue(false);
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
rubricPrompt: `file://${mockFilePath}`,
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Test passed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
await expect(matchesLlmRubric(rubric, llmOutput, grading)).rejects.toThrow(
'File does not exist',
);
expect(mockExistsSync).toHaveBeenCalledWith(
expect.stringContaining(path.join('path', 'to', 'external', 'rubric.txt')),
);
expect(mockReadFileSync).not.toHaveBeenCalled();
expect(grading.provider.callApi).not.toHaveBeenCalled();
});
it('should not call remote when rubric prompt is overridden, even if redteam is enabled', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
rubricPrompt: 'Custom prompt',
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Test passed' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
// Give it a redteam config
cliState.config = { redteam: {} };
await matchesLlmRubric(rubric, llmOutput, grading);
const { doRemoteGrading } = remoteGrading;
expect(doRemoteGrading).not.toHaveBeenCalled();
expect(grading.provider.callApi).toHaveBeenCalledWith(
expect.stringContaining('Custom prompt'),
expect.objectContaining({
prompt: expect.objectContaining({
label: 'llm-rubric',
raw: expect.stringContaining('Custom prompt'),
}),
vars: expect.objectContaining({
output: 'Test output',
rubric: 'Test rubric',
}),
}),
);
});
it('should not call remote when a grading provider is configured, even if redteam is enabled', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Local provider used' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
(cliState as any).config = { redteam: {} };
const result = await matchesLlmRubric(rubric, llmOutput, grading);
expect(remoteGrading.doRemoteGrading).not.toHaveBeenCalled();
expect(grading.provider.callApi).toHaveBeenCalled();
expect(result.reason).toBe('Local provider used');
});
it('should call remote when a caller prefers remote despite an injected default provider', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
provider: createMockProvider({
id: 'implicit-default-provider',
response: createProviderResponse({
output: JSON.stringify({ pass: true, score: 1, reason: 'Local provider used' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
}),
}),
};
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
vi.mocked(remoteGrading.doRemoteGrading).mockResolvedValue({
pass: true,
score: 1,
reason: 'Remote grading passed',
});
(cliState as any).config = { redteam: {} };
const result = await matchesLlmRubric(rubric, llmOutput, grading, undefined, undefined, {
preferRemote: true,
});
expect(remoteGrading.doRemoteGrading).toHaveBeenCalledWith({
task: 'llm-rubric',
rubric,
output: llmOutput,
vars: {},
});
expect(grading.provider.callApi).not.toHaveBeenCalled();
expect(result.reason).toBe('Remote grading passed');
});
it('should include Cloud target context in remote grading requests', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
(cliState as any).config = {
providers: ['promptfoo://provider/cloud-target-123'],
redteam: {},
};
await matchesLlmRubric(rubric, llmOutput, {});
expect(remoteGrading.doRemoteGrading).toHaveBeenCalledWith({
task: 'llm-rubric',
rubric,
output: llmOutput,
vars: {},
targetId: 'cloud-target-123',
});
});
it('should prefer filtered providers when building remote grading context', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
cliState.config = {
providers: ['promptfoo://provider/excluded-target'],
redteam: {},
};
cliState.selectedProviderConfigs = ['promptfoo://provider/selected-target'];
await matchesLlmRubric(rubric, llmOutput, {});
expect(remoteGrading.doRemoteGrading).toHaveBeenCalledWith({
task: 'llm-rubric',
rubric,
output: llmOutput,
vars: {},
targetId: 'selected-target',
});
});
it('should call remote with image outputs when multimodal grading is remote-eligible', async () => {
const rubric = 'Does the image match?';
const llmOutput = 'Generated image';
const grading = {};
const vars = { expectedColor: 'blue' };
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
vi.mocked(remoteGrading.doRemoteGrading).mockResolvedValue({
pass: true,
score: 1,
reason: 'Remote multimodal grading passed',
});
(cliState as any).config = { redteam: {} };
const result = await matchesLlmRubric(rubric, llmOutput, grading, vars, undefined, {
providerResponse: {
output: llmOutput,
images: [{ data: 'abc123', mimeType: 'image/webp' }],
},
});
expect(remoteGrading.doRemoteGrading).toHaveBeenCalledWith({
task: 'llm-rubric',
rubric,
output: llmOutput,
vars,
images: [{ data: 'data:image/webp;base64,abc123', mimeType: 'image/webp' }],
});
expect(DefaultGradingProvider.callApi).not.toHaveBeenCalled();
expect(result.reason).toBe('Remote multimodal grading passed');
});
it('should replace image data URI text output for remote multimodal grading', async () => {
const rubric = 'Does the image match?';
const llmOutput = 'data:image/webp;base64,abc123';
const grading = {};
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
vi.mocked(remoteGrading.doRemoteGrading).mockResolvedValue({
pass: true,
score: 1,
reason: 'Remote multimodal grading passed',
});
(cliState as any).config = { redteam: {} };
await matchesLlmRubric(rubric, llmOutput, grading, {}, undefined, {
providerResponse: {
output: llmOutput,
images: [{ data: llmOutput, mimeType: 'image/webp' }],
},
});
expect(remoteGrading.doRemoteGrading).toHaveBeenCalledWith({
task: 'llm-rubric',
rubric,
output: '[Image output attached. Inspect the attached image directly for visual grading.]',
vars: {},
images: [{ data: 'data:image/webp;base64,abc123', mimeType: 'image/webp' }],
});
});
it('should use local multimodal grading when a local grading provider is configured', async () => {
const rubric = 'Does the image match?';
const llmOutput = 'Generated image';
const provider = createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Local multimodal grading passed' }),
},
});
const grading = {
provider,
};
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
(cliState as any).config = { redteam: {} };
const result = await matchesLlmRubric(rubric, llmOutput, grading, {}, undefined, {
providerResponse: {
output: llmOutput,
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
});
expect(remoteGrading.doRemoteGrading).not.toHaveBeenCalled();
expect(provider.callApi).toHaveBeenCalled();
const prompt = provider.callApi.mock.calls[0][0] as string;
expect(JSON.parse(prompt)).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: expect.arrayContaining([
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,abc123' },
},
]),
}),
]),
);
expect(result.reason).toBe('Local multimodal grading passed');
});
it('should call remote when redteam is enabled and rubric prompt is not overridden and no provider is configured', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
// No provider configured - this is the key change
const grading = {};
// Clear and set up specific mock behavior for this test
vi.mocked(remoteGrading.doRemoteGrading).mockClear();
vi.mocked(remoteGrading.doRemoteGrading).mockResolvedValue({
pass: true,
score: 1,
reason: 'Remote grading passed',
});
// Import and set up shouldGenerateRemote mock properly
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
// Give it a redteam config
(cliState as any).config = { redteam: {} };
await matchesLlmRubric(rubric, llmOutput, grading);
const { doRemoteGrading } = remoteGrading;
expect(doRemoteGrading).toHaveBeenCalledWith({
task: 'llm-rubric',
rubric,
output: llmOutput,
vars: {},
});
expect(remoteGeneration.shouldGenerateRemote).toHaveBeenCalledWith({
canUseCodexDefaultProvider: true,
});
});
it('should tag remote-grading transport failures with metadata.graderError', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {};
vi.mocked(remoteGrading.doRemoteGrading).mockClear();
vi.mocked(remoteGrading.doRemoteGrading).mockRejectedValue(new Error('network down'));
const remoteGeneration = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration.shouldGenerateRemote).mockReturnValue(true);
(cliState as any).config = { redteam: {} };
const result = await matchesLlmRubric(rubric, llmOutput, grading);
expect(result.pass).toBe(false);
expect(result.score).toBe(0);
expect(result.reason).toContain('Could not perform remote grading');
expect(result.metadata?.graderError).toBe(true);
});
it('should use local provider when redteam.provider is configured even if remote generation is available', async () => {
const rubric = 'Test rubric';
const llmOutput = 'Test output';
const grading = {
provider: createMockProvider({
response: {
output: JSON.stringify({ pass: true, score: 1, reason: 'Local provider used' }),
tokenUsage: { total: 10, prompt: 5, completion: 5 },
},
}),
};
// Clear and set up specific mock behavior for this test
vi.mocked(remoteGrading.doRemoteGrading).mockClear();
vi.mocked(remoteGrading.doRemoteGrading).mockResolvedValue({
pass: true,
score: 1,
reason: 'Remote grading passed',
});
// Import and set up shouldGenerateRemote mock properly
const remoteGeneration2 = await import('../../src/redteam/remoteGeneration');
vi.mocked(remoteGeneration2.shouldGenerateRemote).mockReturnValue(true);
// Give it a redteam config WITH a provider configured
(cliState as any).config = { redteam: { provider: 'ollama:llama3.2' } };
const result = await matchesLlmRubric(rubric, llmOutput, grading);
// Remote grading should NOT be called when redteam.provider is configured
const { doRemoteGrading } = remoteGrading;
expect(doRemoteGrading).not.toHaveBeenCalled();
// Local provider should be used instead
expect(grading.provider.callApi).toHaveBeenCalled();
expect(result.reason).toBe('Local provider used');
});
});
describe('tryParse and renderLlmRubricPrompt', () => {
let tryParse: (content: string | null | undefined) => any;
beforeAll(async () => {
const context: { capturedFn: null | Function } = { capturedFn: null };
await renderLlmRubricPrompt('{"test":"value"}', {
__capture(fn: Function) {
context.capturedFn = fn;
return 'captured';
},
});
tryParse = function (content: string | null | undefined) {
try {
if (content === null || content === undefined) {
return content;
}
return JSON.parse(content);
} catch {}
return content;
};
});
it('should parse valid JSON', () => {
const input = '{"key": "value"}';
expect(tryParse(input)).toEqual({ key: 'value' });
});
it('should return original string for invalid JSON', () => {
const input = 'not json';
expect(tryParse(input)).toBe('not json');
});
it('should handle empty string', () => {
const input = '';
expect(tryParse(input)).toBe('');
});
it('should handle null and undefined', () => {
expect(tryParse(null)).toBeNull();
expect(tryParse(undefined)).toBeUndefined();
});
it('should render strings inside JSON objects', async () => {
const template = '{"role": "user", "content": "Hello {{name}}"}';
const result = await renderLlmRubricPrompt(template, { name: 'World' });
const parsed = JSON.parse(result);
expect(parsed).toEqual({ role: 'user', content: 'Hello World' });
});
it('should preserve JSON structure while rendering only strings', async () => {
const template = '{"nested": {"text": "{{var}}", "number": 42}}';
const result = await renderLlmRubricPrompt(template, { var: 'test' });
const parsed = JSON.parse(result);
expect(parsed).toEqual({ nested: { text: 'test', number: 42 } });
});
it('should handle non-JSON templates with legacy rendering', async () => {
const template = 'Hello {{name}}';
const result = await renderLlmRubricPrompt(template, { name: 'World' });
expect(result).toBe('Hello World');
});
it('should handle complex objects in context', async () => {
const template = '{"text": "{{object}}"}';
const complexObject = { foo: 'bar', baz: [1, 2, 3] };
const result = await renderLlmRubricPrompt(template, { object: complexObject });
const parsed = JSON.parse(result);
expect(typeof parsed.text).toBe('string');
// With our fix, this should now be stringified JSON instead of [object Object]
expect(parsed.text).toBe(JSON.stringify(complexObject));
});
it('should properly stringify objects', async () => {
const template = 'Source Text:\n{{input}}';
// Create objects that would typically cause the [object Object] issue
const objects = [
{ name: 'Object 1', properties: { color: 'red', size: 'large' } },
{ name: 'Object 2', properties: { color: 'blue', size: 'small' } },
];
const result = await renderLlmRubricPrompt(template, { input: objects });
// With our fix, this should properly stringify the objects
expect(result).not.toContain('[object Object]');
expect(result).toContain(JSON.stringify(objects[0]));
expect(result).toContain(JSON.stringify(objects[1]));
});
it('should handle mixed arrays of objects and primitives', async () => {
const template = 'Items: {{items}}';
const mixedArray = ['string item', { name: 'Object item' }, 42, [1, 2, 3]];
const result = await renderLlmRubricPrompt(template, { items: mixedArray });
// Objects in array should be stringified
expect(result).not.toContain('[object Object]');
expect(result).toContain('string item');
expect(result).toContain(JSON.stringify({ name: 'Object item' }));
expect(result).toContain('42');
expect(result).toContain(JSON.stringify([1, 2, 3]));
});
it('should render arrays of objects correctly', async () => {
const template = '{"items": [{"name": "{{name1}}"}, {"name": "{{name2}}"}]}';
const result = await renderLlmRubricPrompt(template, { name1: 'Alice', name2: 'Bob' });
const parsed = JSON.parse(result);
expect(parsed).toEqual({
items: [{ name: 'Alice' }, { name: 'Bob' }],
});
});
it('should handle multiline strings', async () => {
const template = `{"content": "Line 1\\nLine {{number}}\\nLine 3"}`;
const result = await renderLlmRubricPrompt(template, { number: '2' });
const parsed = JSON.parse(result);
expect(parsed).toEqual({
content: 'Line 1\nLine 2\nLine 3',
});
});
it('should handle nested templates', async () => {
const template = '{"outer": "{{value1}}", "inner": {"value": "{{value2}}"}}';
const result = await renderLlmRubricPrompt(template, {
value1: 'outer value',
value2: 'inner value',
});
const parsed = JSON.parse(result);
expect(parsed).toEqual({
outer: 'outer value',
inner: { value: 'inner value' },
});
});
it('should handle escaping in JSON strings', async () => {
const template = '{"content": "This needs \\"escaping\\" and {{var}} too"}';
const result = await renderLlmRubricPrompt(template, { var: 'var with "quotes"' });
const parsed = JSON.parse(result);
expect(parsed.content).toBe('This needs "escaping" and var with "quotes" too');
});
it('should work with nested arrays and objects', async () => {
const template = JSON.stringify({
role: 'system',
content: 'Process this: {{input}}',
config: {
options: [
{ id: 1, label: '{{option1}}' },
{ id: 2, label: '{{option2}}' },
],
},
});
const evalResult = await renderLlmRubricPrompt(template, {
input: 'test input',
option1: 'First Option',
option2: 'Second Option',
});
const parsed = JSON.parse(evalResult);
expect(parsed.content).toBe('Process this: test input');
expect(parsed.config.options[0].label).toBe('First Option');
expect(parsed.config.options[1].label).toBe('Second Option');
});
it('should handle rendering statements with join filter', async () => {
const statements = ['Statement 1', 'Statement 2', 'Statement 3'];
const template = 'statements:\n{{statements|join("\\n")}}';
const result = await renderLlmRubricPrompt(template, { statements });
const expected = 'statements:\nStatement 1\nStatement 2\nStatement 3';
expect(result).toBe(expected);
});
it('should stringify objects in arrays', async () => {
const template = 'Items: {{items}}';
const items = [{ name: 'Item 1', price: 10 }, 'string item', { name: 'Item 2', price: 20 }];
const result = await renderLlmRubricPrompt(template, { items });
expect(result).not.toContain('[object Object]');
expect(result).toContain(JSON.stringify(items[0]));
expect(result).toContain('string item');
expect(result).toContain(JSON.stringify(items[2]));
});
it('should stringify deeply nested objects and arrays', async () => {
const template = 'Complex data: {{data}}';
const data = {
products: [
{
name: 'Item 1',
price: 10,
details: {
color: 'red',
specs: { weight: '2kg', dimensions: { width: 10, height: 20 } },
},
},
'string item',
{
name: 'Item 2',
price: 20,
nested: [{ a: 1 }, { b: 2 }],
metadata: { tags: ['electronics', 'gadget'] },
},
],
};
const result = await renderLlmRubricPrompt(template, { data });
expect(result).not.toContain('[object Object]');
expect(result).toContain('"specs":{"weight":"2kg"');
expect(result).toContain('"dimensions":{"width":10,"height":20}');
expect(result).toContain('[{"a":1},{"b":2}]');
expect(result).toContain('"tags":["electronics","gadget"]');
expect(result).toContain('string item');
});
});