Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

460 lines
16 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import cliState from '../../src/cliState';
import {
getAndCheckProvider,
getGradingProvider,
getRemoteGradingContext,
} from '../../src/matchers/providers';
import { renderLlmRubricPrompt } from '../../src/matchers/rubric';
import {
DefaultEmbeddingProvider,
DefaultGradingProvider,
} from '../../src/providers/openai/defaults';
import { createMockProvider } from '../factories/provider';
import { mockProcessEnv } from '../util/utils';
import type { ProviderTypeMap } from '../../src/types/index';
describe('getRemoteGradingContext', () => {
beforeEach(() => {
cliState.config = undefined;
cliState.selectedProviderConfigs = undefined;
});
afterEach(() => {
cliState.config = undefined;
cliState.selectedProviderConfigs = undefined;
});
it('prefers the actively selected provider configs', () => {
cliState.config = { providers: ['promptfoo://provider/excluded-target'] };
cliState.selectedProviderConfigs = ['promptfoo://provider/selected-target'];
expect(getRemoteGradingContext()).toEqual({ targetId: 'selected-target' });
});
it('falls back to the configured providers', () => {
cliState.config = { providers: ['promptfoo://provider/configured-target'] };
expect(getRemoteGradingContext()).toEqual({ targetId: 'configured-target' });
});
it('does not fall back to configured providers when the filter matched nothing', () => {
cliState.config = { providers: ['promptfoo://provider/configured-target'] };
cliState.selectedProviderConfigs = [];
expect(getRemoteGradingContext()).toEqual({});
});
});
describe('getGradingProvider', () => {
it('should return the correct provider when provider is a string', async () => {
const provider = await getGradingProvider(
'text',
'openai:chat:gpt-4o-mini-foobar',
DefaultGradingProvider,
);
// ok for this not to match exactly when the string is parsed
expect(provider?.id()).toBe('openai:gpt-4o-mini-foobar');
});
it('should return the correct provider when provider is an ApiProvider', async () => {
const provider = await getGradingProvider(
'embedding',
DefaultEmbeddingProvider,
DefaultGradingProvider,
);
expect(provider).toBe(DefaultEmbeddingProvider);
});
it('should return the correct provider when provider is ProviderOptions', async () => {
const providerOptions = {
id: 'openai:chat:gpt-4o-mini-foobar',
config: {
apiKey: 'abc123',
temperature: 3.1415926,
},
};
const provider = await getGradingProvider('text', providerOptions, DefaultGradingProvider);
expect(provider?.id()).toBe('openai:chat:gpt-4o-mini-foobar');
});
it('should return the default provider when provider is not provided', async () => {
const provider = await getGradingProvider('text', undefined, DefaultGradingProvider);
expect(provider).toBe(DefaultGradingProvider);
});
});
describe('getAndCheckProvider', () => {
it('should return the default provider when provider is not defined', async () => {
await expect(
getAndCheckProvider('text', undefined, DefaultGradingProvider, 'test check'),
).resolves.toBe(DefaultGradingProvider);
});
it('should throw when explicitly configured provider does not support type', async () => {
const provider = {
id: () => 'test-provider',
callApi: () => Promise.resolve({ output: 'test' }),
};
await expect(
getAndCheckProvider('embedding', provider, DefaultEmbeddingProvider, 'test check'),
).rejects.toThrow('is not a valid embedding provider');
});
it('should return the provider if it implements the required method', async () => {
const provider = {
id: () => 'test-provider',
callApi: () => Promise.resolve({ output: 'test' }),
callEmbeddingApi: () => Promise.resolve({ embedding: [] }),
};
const result = await getAndCheckProvider(
'embedding',
provider,
DefaultEmbeddingProvider,
'test check',
);
expect(result).toBe(provider);
});
it('should return the default provider when no provider is specified', async () => {
const provider = await getGradingProvider('text', undefined, DefaultGradingProvider);
expect(provider).toBe(DefaultGradingProvider);
});
it('should return a specific provider when a provider id is specified', async () => {
const provider = await getGradingProvider('text', 'openai:chat:foo', DefaultGradingProvider);
// loadApiProvider removes `chat` from the id
expect(provider?.id()).toBe('openai:foo');
});
it('should return a provider from ApiProvider when specified', async () => {
const providerOptions = createMockProvider({ id: 'custom-provider', response: {} });
const provider = await getGradingProvider('text', providerOptions, DefaultGradingProvider);
expect(provider?.id()).toBe('custom-provider');
});
it('should return a provider from ProviderTypeMap when specified', async () => {
const providerTypeMap: ProviderTypeMap = {
text: {
id: 'openai:chat:foo',
},
embedding: {
id: 'openai:embedding:bar',
},
};
const provider = await getGradingProvider('text', providerTypeMap, DefaultGradingProvider);
expect(provider?.id()).toBe('openai:chat:foo');
});
it('should return a provider from ProviderTypeMap with basic strings', async () => {
const providerTypeMap: ProviderTypeMap = {
text: 'openai:chat:foo',
embedding: 'openai:embedding:bar',
};
const provider = await getGradingProvider('text', providerTypeMap, DefaultGradingProvider);
expect(provider?.id()).toBe('openai:foo');
});
it('should throw an error when the provider does not match the type', async () => {
const providerTypeMap: ProviderTypeMap = {
embedding: {
id: 'openai:embedding:foo',
},
};
await expect(
getGradingProvider('text', providerTypeMap, DefaultGradingProvider),
).rejects.toThrow(
new Error(
`Invalid provider definition for output type 'text': ${JSON.stringify(
providerTypeMap,
null,
2,
)}`,
),
);
});
});
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');
});
});
describe('PROMPTFOO_DISABLE_OBJECT_STRINGIFY environment variable', () => {
afterEach(() => {
// Clean up environment variable after each test
mockProcessEnv({ PROMPTFOO_DISABLE_OBJECT_STRINGIFY: undefined });
});
describe('Default behavior (PROMPTFOO_DISABLE_OBJECT_STRINGIFY=false)', () => {
beforeEach(() => {
mockProcessEnv({ PROMPTFOO_DISABLE_OBJECT_STRINGIFY: 'false' });
});
it('should stringify objects to prevent [object Object] issues', async () => {
const template = 'Product: {{product}}';
const product = { name: 'Headphones', price: 99.99 };
const result = await renderLlmRubricPrompt(template, { product });
expect(result).not.toContain('[object Object]');
expect(result).toBe(`Product: ${JSON.stringify(product)}`);
});
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]));
});
});
describe('Object access enabled (PROMPTFOO_DISABLE_OBJECT_STRINGIFY=true)', () => {
beforeEach(() => {
mockProcessEnv({ PROMPTFOO_DISABLE_OBJECT_STRINGIFY: 'true' });
});
it('should allow direct object property access', async () => {
const template = 'Product: {{product.name}} - ${{product.price}}';
const product = { name: 'Headphones', price: 99.99 };
const result = await renderLlmRubricPrompt(template, { product });
expect(result).toBe('Product: Headphones - $99.99');
});
it('should allow array indexing and property access', async () => {
const template = 'First item: {{items[0].name}}';
const items = [
{ name: 'First Item', price: 10 },
{ name: 'Second Item', price: 20 },
];
const result = await renderLlmRubricPrompt(template, { items });
expect(result).toBe('First item: First Item');
});
});
});