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
1419 lines
42 KiB
TypeScript
1419 lines
42 KiB
TypeScript
import fs from 'fs';
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { handleIsValidFunctionCall } from '../../src/assertions/functionToolCall';
|
|
import { runAssertion } from '../../src/assertions/index';
|
|
import { handleIsValidOpenAiToolsCall } from '../../src/assertions/openai';
|
|
import { hasFunctionToolCallValidator } from '../../src/contracts/providers';
|
|
import { OpenAiChatCompletionProvider } from '../../src/providers/openai/chat';
|
|
import { validateFunctionCall } from '../../src/providers/openai/util';
|
|
import { createMockProvider } from '../factories/provider';
|
|
|
|
import type { OpenAiTool } from '../../src/providers/openai/util';
|
|
import type {
|
|
ApiProvider,
|
|
Assertion,
|
|
AssertionValueFunctionContext,
|
|
AtomicTestCase,
|
|
GradingResult,
|
|
} from '../../src/types/index';
|
|
|
|
// Create hoisted mocks for stable references
|
|
const mocks = vi.hoisted(() => ({
|
|
mockPathResolve: vi.fn(),
|
|
mockMaybeLoadToolsFromExternalFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('fs');
|
|
vi.mock('path', async () => {
|
|
const actual = await vi.importActual<typeof import('path')>('path');
|
|
return {
|
|
...actual,
|
|
resolve: mocks.mockPathResolve,
|
|
};
|
|
});
|
|
vi.mock('../../src/util', async () => {
|
|
const actual = await vi.importActual('../../src/util');
|
|
return {
|
|
...actual,
|
|
maybeLoadToolsFromExternalFile: mocks.mockMaybeLoadToolsFromExternalFile,
|
|
};
|
|
});
|
|
|
|
const mockedFs = vi.mocked(fs);
|
|
|
|
const toolsAssertion: Assertion = {
|
|
type: 'is-valid-openai-tools-call',
|
|
};
|
|
|
|
const functionAssertion: Assertion = {
|
|
type: 'is-valid-openai-function-call',
|
|
};
|
|
|
|
const mockProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['Celsius', 'Fahrenheit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
functions: [
|
|
{
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['Celsius', 'Fahrenheit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const mockContext: AssertionValueFunctionContext = {
|
|
prompt: '',
|
|
vars: {},
|
|
test: { vars: {} },
|
|
logProbs: [],
|
|
provider: mockProvider,
|
|
providerResponse: { output: '' },
|
|
};
|
|
|
|
describe('OpenAI assertions', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mocks.mockPathResolve.mockImplementation((...args: string[]) => args[args.length - 1]);
|
|
mockedFs.existsSync.mockReturnValue(true);
|
|
mocks.mockMaybeLoadToolsFromExternalFile.mockImplementation((input) => input);
|
|
});
|
|
|
|
describe('is-valid-openai-function-call assertion', () => {
|
|
it('should pass for a valid function call with correct arguments', async () => {
|
|
const output = { arguments: '{"x": 10, "y": 20}', name: 'add' };
|
|
|
|
const provider = new OpenAiChatCompletionProvider('foo', {
|
|
config: {
|
|
functions: [
|
|
{
|
|
name: 'add',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
x: { type: 'number' },
|
|
y: { type: 'number' },
|
|
},
|
|
required: ['x', 'y'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const providerResponse = { output };
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion: {
|
|
type: 'is-valid-openai-function-call',
|
|
},
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass for a nested function_call output', async () => {
|
|
const output = {
|
|
function_call: { arguments: '{"x": 10, "y": 20}', name: 'add' },
|
|
};
|
|
|
|
const provider = new OpenAiChatCompletionProvider('foo', {
|
|
config: {
|
|
functions: [
|
|
{
|
|
name: 'add',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
x: { type: 'number' },
|
|
y: { type: 'number' },
|
|
},
|
|
required: ['x', 'y'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const providerResponse = { output };
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion: {
|
|
type: 'is-valid-openai-function-call',
|
|
},
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fail for an invalid function call with incorrect arguments', async () => {
|
|
const output = { arguments: '{"x": "10", "y": 20}', name: 'add' };
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('foo', {
|
|
config: {
|
|
functions: [
|
|
{
|
|
name: 'add',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
x: { type: 'number' },
|
|
y: { type: 'number' },
|
|
},
|
|
required: ['x', 'y'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
assertion: {
|
|
type: 'is-valid-openai-function-call',
|
|
},
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
reason: expect.stringContaining('Call to "add" does not match schema'),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleIsValidFunctionCall', () => {
|
|
it.each([
|
|
['undefined provider', undefined, false],
|
|
['null provider', null, false],
|
|
['primitive provider', 'provider', false],
|
|
['missing capability', {}, false],
|
|
['string capability', { validateFunctionToolCall: 'validate' }, false],
|
|
['boolean capability', { validateFunctionToolCall: true }, false],
|
|
['object capability', { validateFunctionToolCall: {} }, false],
|
|
['function capability', { validateFunctionToolCall: vi.fn() }, true],
|
|
])('identifies %s', (_label, provider, expected) => {
|
|
expect(hasFunctionToolCallValidator(provider)).toBe(expected);
|
|
});
|
|
|
|
it('should fail when provider does not expose the validator capability', () => {
|
|
const output = { arguments: '{"x": 10}', name: 'add' };
|
|
|
|
const result = handleIsValidFunctionCall({
|
|
assertion: functionAssertion,
|
|
output,
|
|
provider: createMockProvider(),
|
|
test: { vars: {} },
|
|
baseType: functionAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(output),
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'Provider does not have functionality for checking function call.',
|
|
assertion: functionAssertion,
|
|
});
|
|
});
|
|
|
|
it('should delegate to a structural provider capability', () => {
|
|
const validateFunctionToolCall = vi.fn();
|
|
const provider = {
|
|
...createMockProvider(),
|
|
validateFunctionToolCall,
|
|
};
|
|
const output = { arguments: '{"x": 10}', name: 'add' };
|
|
|
|
const result = handleIsValidFunctionCall({
|
|
assertion: functionAssertion,
|
|
output,
|
|
provider,
|
|
test: { vars: { value: 10 } },
|
|
baseType: functionAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(output),
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(validateFunctionToolCall).toHaveBeenCalledWith(output, { value: 10 });
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: functionAssertion,
|
|
});
|
|
});
|
|
|
|
it('should pass when function call matches schema', async () => {
|
|
const functionOutput = {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "Fahrenheit"}',
|
|
};
|
|
|
|
const result = handleIsValidFunctionCall({
|
|
assertion: functionAssertion,
|
|
output: functionOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: functionAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(functionOutput),
|
|
providerResponse: { output: functionOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: functionAssertion,
|
|
});
|
|
});
|
|
|
|
it('should load functions from external file', async () => {
|
|
const functionOutput = {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "Fahrenheit"}',
|
|
};
|
|
|
|
const mockYamlContent = `
|
|
- name: getCurrentTemperature
|
|
parameters:
|
|
type: object
|
|
properties:
|
|
location:
|
|
type: string
|
|
unit:
|
|
type: string
|
|
enum: [Celsius, Fahrenheit]
|
|
required: [location, unit]
|
|
`;
|
|
|
|
mockedFs.readFileSync.mockReturnValue(mockYamlContent);
|
|
|
|
const fileProvider = createMockProvider({
|
|
config: { functions: 'file://./test/fixtures/weather_functions.yaml' },
|
|
response: { output: '' },
|
|
}) as ApiProvider;
|
|
|
|
expect(() => {
|
|
validateFunctionCall(functionOutput, fileProvider.config.functions, {});
|
|
}).not.toThrow();
|
|
|
|
// Note: existsSync is no longer called - we use try/catch on readFileSync instead (TOCTOU fix)
|
|
expect(mockedFs.readFileSync).toHaveBeenCalledWith(
|
|
'./test/fixtures/weather_functions.yaml',
|
|
'utf8',
|
|
);
|
|
});
|
|
|
|
it('should render variables in function definitions', async () => {
|
|
const functionOutput = {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "custom_unit"}',
|
|
};
|
|
|
|
const varProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {
|
|
functions: [
|
|
{
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['{{unit}}'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = handleIsValidFunctionCall({
|
|
assertion: functionAssertion,
|
|
output: functionOutput,
|
|
provider: varProvider,
|
|
test: { vars: { unit: 'custom_unit' } },
|
|
baseType: functionAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(functionOutput),
|
|
providerResponse: { output: functionOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: functionAssertion,
|
|
});
|
|
});
|
|
|
|
it('should fail when functions are not defined', async () => {
|
|
const functionOutput = {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA"}',
|
|
};
|
|
|
|
const emptyProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {},
|
|
});
|
|
|
|
const result = handleIsValidFunctionCall({
|
|
assertion: functionAssertion,
|
|
output: functionOutput,
|
|
provider: emptyProvider,
|
|
test: { vars: {} },
|
|
baseType: functionAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(functionOutput),
|
|
providerResponse: { output: functionOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'Called "getCurrentTemperature", but there is no function with that name',
|
|
assertion: functionAssertion,
|
|
});
|
|
});
|
|
|
|
it('should fail when function output is not an object', async () => {
|
|
const functionOutput = 'not an object';
|
|
|
|
const result = handleIsValidFunctionCall({
|
|
assertion: functionAssertion,
|
|
output: functionOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: functionAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(functionOutput),
|
|
providerResponse: { output: functionOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: expect.stringContaining('OpenAI did not return a valid-looking function call'),
|
|
assertion: functionAssertion,
|
|
});
|
|
});
|
|
|
|
it('should fail when function call does not match schema', async () => {
|
|
const functionOutput = {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA"}', // missing required 'unit'
|
|
};
|
|
|
|
const result = handleIsValidFunctionCall({
|
|
assertion: functionAssertion,
|
|
output: functionOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: functionAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(functionOutput),
|
|
providerResponse: { output: functionOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: expect.stringContaining('must have required property'),
|
|
assertion: functionAssertion,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('is-valid-openai-tools-call assertion', () => {
|
|
it('should pass for a valid tools call with correct arguments', async () => {
|
|
const output = [
|
|
{ type: 'function', function: { arguments: '{"x": 10, "y": 20}', name: 'add' } },
|
|
];
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('foo', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'add',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
x: { type: 'number' },
|
|
y: { type: 'number' },
|
|
},
|
|
required: ['x', 'y'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
assertion: {
|
|
type: 'is-valid-openai-tools-call',
|
|
},
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass for a nested tool_calls output', async () => {
|
|
const output = {
|
|
content: 'Let me check the weather...',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco", "unit": "Fahrenheit"}',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('foo', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['Celsius', 'Fahrenheit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
assertion: {
|
|
type: 'is-valid-openai-tools-call',
|
|
},
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass for multiple tool calls in parallel', async () => {
|
|
const output = {
|
|
content: 'Checking weather data...',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "London", "unit": "Celsius"}',
|
|
},
|
|
},
|
|
{
|
|
id: 'call_456',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getRainProbability',
|
|
arguments: '{"location": "London"}',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('foo', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['Celsius', 'Fahrenheit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getRainProbability',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
},
|
|
required: ['location'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
assertion: {
|
|
type: 'is-valid-openai-tools-call',
|
|
},
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fail for an invalid tools call with incorrect arguments', async () => {
|
|
const output = [
|
|
{ type: 'function', function: { arguments: '{"x": "foobar", "y": 20}', name: 'add' } },
|
|
];
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('foo', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'add',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
x: { type: 'number' },
|
|
y: { type: 'number' },
|
|
},
|
|
required: ['x', 'y'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
assertion: {
|
|
type: 'is-valid-openai-tools-call',
|
|
},
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
reason: expect.stringContaining('Call to "add" does not match schema'),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleIsValidOpenAiToolsCall', () => {
|
|
it('should pass when tool calls match schema', async () => {
|
|
const toolsOutput = [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "Fahrenheit"}',
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolsOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolsOutput),
|
|
providerResponse: { output: toolsOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should load tools from external file', async () => {
|
|
const toolsOutput = [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "Fahrenheit"}',
|
|
},
|
|
},
|
|
];
|
|
|
|
// Define the array of tools that should be returned by the mock
|
|
const mockParsedTools = [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['Celsius', 'Fahrenheit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
// Make sure the mock returns an array, not a string or object
|
|
mocks.mockMaybeLoadToolsFromExternalFile.mockResolvedValue(mockParsedTools);
|
|
|
|
const fileProvider = createMockProvider({
|
|
config: {
|
|
tools: 'file://./test/fixtures/weather_tools.json' as unknown as OpenAiTool[],
|
|
},
|
|
response: { output: '' },
|
|
}) as ApiProvider;
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolsOutput,
|
|
provider: fileProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolsOutput),
|
|
providerResponse: { output: toolsOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: toolsAssertion,
|
|
});
|
|
|
|
// Verify mocks.mockMaybeLoadToolsFromExternalFile was called with the file path
|
|
expect(mocks.mockMaybeLoadToolsFromExternalFile).toHaveBeenCalledWith(
|
|
'file://./test/fixtures/weather_tools.json',
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should render variables in tool definitions', async () => {
|
|
const toolsOutput = [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "custom_unit"}',
|
|
},
|
|
},
|
|
];
|
|
|
|
const varProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['{{unit}}'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolsOutput,
|
|
provider: varProvider,
|
|
test: { vars: { unit: 'custom_unit' } },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolsOutput),
|
|
providerResponse: { output: toolsOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should fail when tools are not defined', async () => {
|
|
const toolsOutput = [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA"}',
|
|
},
|
|
},
|
|
];
|
|
|
|
const emptyProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {},
|
|
});
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolsOutput,
|
|
provider: emptyProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolsOutput),
|
|
providerResponse: { output: toolsOutput },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'No tools configured in provider, but output contains tool calls',
|
|
});
|
|
});
|
|
|
|
it('should fail when tool output is not an array', async () => {
|
|
const toolsOutput = {
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA"}',
|
|
},
|
|
};
|
|
|
|
const emptyToolsProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {
|
|
tools: [],
|
|
},
|
|
});
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolsOutput,
|
|
provider: emptyToolsProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolsOutput),
|
|
providerResponse: { output: toolsOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: expect.stringContaining('OpenAI did not return a valid-looking tools response'),
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should fail when tool call does not match schema', async () => {
|
|
const toolsOutput = [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA"}', // missing required 'unit'
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolsOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolsOutput),
|
|
providerResponse: { output: toolsOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: expect.stringContaining('must have required property'),
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should use maybeLoadToolsFromExternalFile to process tools', async () => {
|
|
const toolOutput = {
|
|
tool_calls: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "Fahrenheit"}',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const mockTools = [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['Celsius', 'Fahrenheit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
// Set up the mock to return processed tools
|
|
mocks.mockMaybeLoadToolsFromExternalFile.mockResolvedValue(mockTools);
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolOutput,
|
|
provider: mockProvider,
|
|
test: { vars: { city: 'San Francisco, CA' } },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolOutput),
|
|
providerResponse: { output: toolOutput },
|
|
});
|
|
|
|
expect(mocks.mockMaybeLoadToolsFromExternalFile).toHaveBeenCalledWith(
|
|
mockProvider.config.tools,
|
|
{
|
|
city: 'San Francisco, CA',
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle external file references for tools', async () => {
|
|
const toolOutput = {
|
|
tool_calls: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "Fahrenheit"}',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
// Mock a provider with tools from a file reference
|
|
const fileProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {
|
|
tools: 'file://./test/fixtures/weather_tools.json' as unknown as OpenAiTool[],
|
|
},
|
|
});
|
|
|
|
// Set up the mock to return processed tools from the external file
|
|
const mockToolsFromFile = [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['Celsius', 'Fahrenheit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
mocks.mockMaybeLoadToolsFromExternalFile.mockResolvedValue(mockToolsFromFile);
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolOutput,
|
|
provider: fileProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: { ...mockContext, provider: fileProvider },
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolOutput),
|
|
providerResponse: { output: toolOutput },
|
|
});
|
|
|
|
// Check that the function was called with the file path
|
|
expect(mocks.mockMaybeLoadToolsFromExternalFile).toHaveBeenCalledWith(
|
|
'file://./test/fixtures/weather_tools.json',
|
|
{},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle variable substitution in tools', async () => {
|
|
const toolOutput = {
|
|
tool_calls: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "custom_unit"}',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
// Create a provider with tools that contain variable placeholders
|
|
const varProvider = new OpenAiChatCompletionProvider('test-provider', {
|
|
config: {
|
|
tools: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['{{unit}}'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
// Set up the mock to return tools with variables resolved
|
|
const processedTools = [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['custom_unit'] },
|
|
},
|
|
required: ['location', 'unit'],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
mocks.mockMaybeLoadToolsFromExternalFile.mockResolvedValue(processedTools);
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolOutput,
|
|
provider: varProvider,
|
|
test: { vars: { unit: 'custom_unit' } },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: { ...mockContext, provider: varProvider },
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolOutput),
|
|
providerResponse: { output: toolOutput },
|
|
});
|
|
|
|
// Verify the function was called with variables
|
|
expect(mocks.mockMaybeLoadToolsFromExternalFile).toHaveBeenCalledWith(
|
|
varProvider.config.tools,
|
|
{
|
|
unit: 'custom_unit',
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleIsValidOpenAiToolsCall with MCP support', () => {
|
|
it('should pass when MCP tool call succeeds', async () => {
|
|
const mcpOutput =
|
|
'MCP Tool Result (ask_question): React is a JavaScript library for building user interfaces.';
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'MCP tool call succeeded for ask_question',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should fail when MCP tool call has an error', async () => {
|
|
const mcpOutput = 'MCP Tool Error (ask_question): Repository not found';
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'MCP tool call failed for ask_question: Repository not found',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle MCP tool result with complex tool name', async () => {
|
|
const mcpOutput =
|
|
'MCP Tool Result (read_wiki_structure): Successfully retrieved repository structure.';
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'MCP tool call succeeded for read_wiki_structure',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle MCP tool error with complex error message', async () => {
|
|
const mcpOutput =
|
|
'MCP Tool Error (stripe_payment): Authentication failed: Invalid API key provided';
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason:
|
|
'MCP tool call failed for stripe_payment: Authentication failed: Invalid API key provided',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle mixed MCP and regular content', async () => {
|
|
const mcpOutput = `
|
|
Here's what I found:
|
|
MCP Tool Result (ask_question): TypeScript is a programming language.
|
|
Based on this information, TypeScript adds static typing to JavaScript.
|
|
`;
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'MCP tool call succeeded for ask_question',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle multiple MCP tool results in output', async () => {
|
|
const mcpOutput = `
|
|
MCP Tool Result (ask_question): First result here.
|
|
MCP Tool Result (read_wiki_structure): Second result here.
|
|
Both tools executed successfully.
|
|
`;
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'MCP tool call succeeded for ask_question',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should prioritize MCP errors over successes', async () => {
|
|
const mcpOutput = `
|
|
MCP Tool Result (ask_question): First result here.
|
|
MCP Tool Error (read_wiki_structure): Failed to read structure.
|
|
Mixed results from tools.
|
|
`;
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'MCP tool call failed for read_wiki_structure: Failed to read structure.',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle MCP tool error without tool name match', async () => {
|
|
const mcpOutput = 'MCP Tool Error: General error occurred';
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'MCP tool call failed for unknown: unknown error',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle MCP tool result without tool name match', async () => {
|
|
const mcpOutput = 'MCP Tool Result: General success';
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: mcpOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: mcpOutput,
|
|
providerResponse: { output: mcpOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'MCP tool call succeeded for unknown',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should fall back to traditional function tool validation when no MCP content', async () => {
|
|
const toolsOutput = [
|
|
{
|
|
id: 'call_123',
|
|
type: 'function',
|
|
function: {
|
|
name: 'getCurrentTemperature',
|
|
arguments: '{"location": "San Francisco, CA", "unit": "Fahrenheit"}',
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: toolsOutput,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(toolsOutput),
|
|
providerResponse: { output: toolsOutput },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Assertion passed',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
|
|
it('should handle object output with MCP content', async () => {
|
|
const outputObject = {
|
|
content: 'MCP Tool Result (ask_question): React is a JavaScript library.',
|
|
metadata: { source: 'mcp' },
|
|
};
|
|
|
|
const result = await handleIsValidOpenAiToolsCall({
|
|
assertion: toolsAssertion,
|
|
output: outputObject,
|
|
provider: mockProvider,
|
|
test: { vars: {} },
|
|
baseType: toolsAssertion.type,
|
|
assertionValueContext: mockContext,
|
|
inverse: false,
|
|
outputString: JSON.stringify(outputObject),
|
|
providerResponse: { output: outputObject },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'MCP tool call succeeded for ask_question',
|
|
assertion: toolsAssertion,
|
|
});
|
|
});
|
|
});
|
|
});
|