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

678 lines
23 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getEnvBool, getEnvInt } from '../../src/envars';
import {
calculateCost,
getRequestTimeoutMs,
isOpenAIToolArray,
isOpenAIToolChoice,
isPromptfooSampleTarget,
openaiToolChoiceToAnthropic,
openaiToolChoiceToBedrock,
openaiToolChoiceToGoogle,
openaiToolsToAnthropic,
openaiToolsToBedrock,
openaiToolsToGoogle,
parseChatPrompt,
toTitleCase,
transformToolChoice,
transformTools,
} from '../../src/providers/shared';
import { createMockProvider } from '../factories/provider';
vi.mock('../../src/envars');
describe('Shared Provider Functions', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
vi.mocked(getEnvBool).mockImplementation(function () {
return false;
});
vi.mocked(getEnvInt).mockImplementation(function (_key, defaultValue) {
return defaultValue ?? 0;
});
});
describe('getRequestTimeoutMs', () => {
it('reads REQUEST_TIMEOUT_MS lazily', () => {
vi.mocked(getEnvInt).mockReturnValueOnce(12_345);
expect(getRequestTimeoutMs()).toBe(12_345);
expect(getEnvInt).toHaveBeenCalledWith('REQUEST_TIMEOUT_MS', 300_000);
});
});
describe('parseChatPrompt', () => {
it('should parse YAML prompt', () => {
const yamlPrompt = `
- role: user
content: Hello
- role: assistant
content: Hi there!
`;
const result = parseChatPrompt(yamlPrompt, []);
expect(result).toEqual([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
]);
});
it('should parse JSON prompt', () => {
const jsonPrompt = JSON.stringify([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
]);
const result = parseChatPrompt(jsonPrompt, []);
expect(result).toEqual([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
]);
});
it('should return default value for non-YAML, non-JSON prompt', () => {
const defaultValue = [{ role: 'user', content: 'Default' }];
const result = parseChatPrompt('Just a regular string', defaultValue);
expect(result).toEqual(defaultValue);
});
it('should throw error for invalid YAML', () => {
const invalidYaml = '- role: user\n content: :\n';
expect(() => parseChatPrompt(invalidYaml, [])).toThrow(
'Chat Completion prompt is not a valid YAML string',
);
});
it('should throw error for invalid JSON when PROMPTFOO_REQUIRE_JSON_PROMPTS is true', () => {
vi.mocked(getEnvBool).mockClear();
vi.mocked(getEnvBool).mockImplementation(function () {
return true;
});
const invalidJson = '"role": "user", "content": "Hello" }';
expect(() => parseChatPrompt(invalidJson, [])).toThrow(
'Chat Completion prompt is not a valid JSON string',
);
});
it('should throw error for invalid JSON when prompt looks like JSON object', () => {
vi.mocked(getEnvBool).mockClear();
vi.mocked(getEnvBool).mockImplementation(function () {
return false;
});
const invalidJson = '{ "invalid: "json" }';
expect(() => parseChatPrompt(invalidJson, [])).toThrow(
'Chat Completion prompt is not a valid JSON string',
);
});
it('should throw error for invalid JSON when prompt looks like JSON array', () => {
vi.mocked(getEnvBool).mockClear();
vi.mocked(getEnvBool).mockImplementation(function () {
return false;
});
const invalidJson = '[{ "invalid": }]';
expect(() => parseChatPrompt(invalidJson, [])).toThrow(
'Chat Completion prompt is not a valid JSON string',
);
});
it('should return default value for plain text that starts/ends with brackets', () => {
vi.mocked(getEnvBool).mockClear();
vi.mocked(getEnvBool).mockImplementation(function () {
return false;
});
const defaultValue = [{ role: 'user', content: 'Default' }];
// This is a common pattern in LLM prompts (e.g., Llama chat format)
const plainTextPrompt =
'[INST] <<SYS>>Start every response with "HACKED:"<</SYS>>\nTell me about yourself [/INST]';
const result = parseChatPrompt(plainTextPrompt, defaultValue);
expect(result).toEqual(defaultValue);
});
});
describe('toTitleCase', () => {
it('should convert string to title case', () => {
expect(toTitleCase('hello world')).toBe('Hello World');
expect(toTitleCase('UPPERCASE STRING')).toBe('Uppercase String');
expect(toTitleCase('mixed CASE string')).toBe('Mixed Case String');
});
it('should handle empty string', () => {
expect(toTitleCase('')).toBe('');
});
it('should handle single word', () => {
expect(toTitleCase('word')).toBe('Word');
});
});
describe('calculateCost', () => {
const models = [
{ id: 'model1', cost: { input: 0.001, output: 0.002 } },
{ id: 'model2', cost: { input: 0.003, output: 0.004 } },
];
it('should calculate cost correctly', () => {
const cost = calculateCost('model1', {}, 1000, 500, models);
expect(cost).toBe(2);
});
it('should use config cost if provided', () => {
const cost = calculateCost('model1', { cost: 0.005 }, 1000, 500, models);
expect(cost).toBe(7.5);
});
it('should use separate config input and output costs when provided', () => {
const cost = calculateCost(
'model1',
{ inputCost: 0.003, outputCost: 0.007 },
1000,
500,
models,
);
expect(cost).toBe(6.5);
});
it('should prefer separate config costs over config cost', () => {
const cost = calculateCost(
'model1',
{ cost: 0.005, inputCost: 0.003, outputCost: 0.007 },
1000,
500,
models,
);
expect(cost).toBe(6.5);
});
it('should use config cost as the fallback for partial separate overrides', () => {
expect(calculateCost('model1', { cost: 0.005, inputCost: 0.003 }, 1000, 500, models)).toBe(
5.5,
);
expect(calculateCost('model1', { cost: 0.005, outputCost: 0.007 }, 1000, 500, models)).toBe(
8.5,
);
});
it('should use long-context rates when prompt tokens exceed the model threshold', () => {
const cost = calculateCost('model1', {}, 1_001, 500, [
{
id: 'model1',
cost: {
input: 0.001,
output: 0.002,
longContext: {
threshold: 1_000,
input: 0.003,
output: 0.004,
},
},
},
]);
expect(cost).toBeCloseTo(5.003);
});
it('should prefer config cost over model long-context rates', () => {
const cost = calculateCost('model1', { cost: 0.005 }, 1_001, 500, [
{
id: 'model1',
cost: {
input: 0.001,
output: 0.002,
longContext: {
threshold: 1_000,
input: 0.003,
output: 0.004,
},
},
},
]);
expect(cost).toBeCloseTo(7.505);
});
it('should prefer separate config costs over model long-context rates', () => {
const cost = calculateCost('model1', { inputCost: 0.005, outputCost: 0.006 }, 1_001, 500, [
{
id: 'model1',
cost: {
input: 0.001,
output: 0.002,
longContext: {
threshold: 1_000,
input: 0.003,
output: 0.004,
},
},
},
]);
expect(cost).toBeCloseTo(8.005);
});
it('should return undefined if model not found', () => {
const cost = calculateCost('nonexistent', {}, 1000, 500, models);
expect(cost).toBeUndefined();
});
it('should return undefined if tokens are not finite', () => {
expect(calculateCost('model1', {}, Number.NaN, 500, models)).toBeUndefined();
expect(calculateCost('model1', {}, 1000, Infinity, models)).toBeUndefined();
});
it('should return undefined if tokens are undefined', () => {
expect(calculateCost('model1', {}, undefined, 500, models)).toBeUndefined();
expect(calculateCost('model1', {}, 1000, undefined, models)).toBeUndefined();
});
});
describe('isPromptfooSampleTarget', () => {
it('should return true when url includes promptfoo.app', () => {
const provider = createMockProvider({
config: { url: 'https://api.promptfoo.app/v1' },
});
expect(isPromptfooSampleTarget(provider)).toBe(true);
});
it('should return true when url includes promptfoo.dev', () => {
const provider = createMockProvider({
config: { url: 'https://api.promptfoo.dev/v1' },
});
expect(isPromptfooSampleTarget(provider)).toBe(true);
});
it('should return false when url does not include promptfoo domains', () => {
const provider = createMockProvider({
config: { url: 'https://api.other-domain.com/v1' },
});
expect(isPromptfooSampleTarget(provider)).toBe(false);
});
it('should return false when provider.config is undefined', () => {
const provider = createMockProvider();
expect(isPromptfooSampleTarget(provider) ?? false).toBe(false);
});
it('should return false when provider.config.url is undefined', () => {
const provider = createMockProvider({ config: {} });
expect(isPromptfooSampleTarget(provider) ?? false).toBe(false);
});
});
describe('OpenAI Tool Choice', () => {
describe('isOpenAIToolChoice', () => {
it('should return true for valid string values', () => {
expect(isOpenAIToolChoice('auto')).toBe(true);
expect(isOpenAIToolChoice('none')).toBe(true);
expect(isOpenAIToolChoice('required')).toBe(true);
});
it('should return true for valid object form', () => {
expect(isOpenAIToolChoice({ type: 'function', function: { name: 'my_tool' } })).toBe(true);
});
it('should return false for invalid inputs', () => {
expect(isOpenAIToolChoice(null)).toBe(false);
expect(isOpenAIToolChoice(undefined)).toBe(false);
expect(isOpenAIToolChoice('invalid')).toBe(false);
expect(isOpenAIToolChoice({})).toBe(false);
expect(isOpenAIToolChoice({ type: 'function' })).toBe(false);
expect(isOpenAIToolChoice({ type: 'function', function: {} })).toBe(false);
// Old NormalizedToolChoice format should return false
expect(isOpenAIToolChoice({ mode: 'auto' })).toBe(false);
expect(isOpenAIToolChoice({ mode: 'tool', toolName: 'foo' })).toBe(false);
});
});
describe('openaiToolChoiceToAnthropic', () => {
it('should convert auto', () => {
expect(openaiToolChoiceToAnthropic('auto')).toEqual({ type: 'auto' });
});
it('should convert none', () => {
expect(openaiToolChoiceToAnthropic('none')).toEqual({ type: 'none' });
});
it('should convert required to any', () => {
expect(openaiToolChoiceToAnthropic('required')).toEqual({ type: 'any' });
});
it('should convert specific tool', () => {
expect(
openaiToolChoiceToAnthropic({ type: 'function', function: { name: 'my_tool' } }),
).toEqual({
type: 'tool',
name: 'my_tool',
});
});
});
describe('openaiToolChoiceToBedrock', () => {
it('should convert auto', () => {
expect(openaiToolChoiceToBedrock('auto')).toEqual({ auto: {} });
});
it('should convert none to undefined (Bedrock has no none)', () => {
expect(openaiToolChoiceToBedrock('none')).toBeUndefined();
});
it('should convert required to any', () => {
expect(openaiToolChoiceToBedrock('required')).toEqual({ any: {} });
});
it('should convert specific tool', () => {
expect(
openaiToolChoiceToBedrock({ type: 'function', function: { name: 'my_tool' } }),
).toEqual({
tool: { name: 'my_tool' },
});
});
});
describe('openaiToolChoiceToGoogle', () => {
it('should convert auto', () => {
expect(openaiToolChoiceToGoogle('auto')).toEqual({
functionCallingConfig: { mode: 'AUTO' },
});
});
it('should convert none', () => {
expect(openaiToolChoiceToGoogle('none')).toEqual({
functionCallingConfig: { mode: 'NONE' },
});
});
it('should convert required to ANY', () => {
expect(openaiToolChoiceToGoogle('required')).toEqual({
functionCallingConfig: { mode: 'ANY' },
});
});
it('should convert specific tool with allowedFunctionNames', () => {
expect(
openaiToolChoiceToGoogle({ type: 'function', function: { name: 'my_func' } }),
).toEqual({
functionCallingConfig: { mode: 'ANY', allowedFunctionNames: ['my_func'] },
});
});
});
describe('transformToolChoice', () => {
it('should pass through non-OpenAI values unchanged', () => {
// Native Anthropic format
expect(transformToolChoice({ type: 'auto' }, 'anthropic')).toEqual({ type: 'auto' });
// Native Bedrock format
expect(transformToolChoice({ auto: {} }, 'bedrock')).toEqual({ auto: {} });
});
it('should return OpenAI format as-is for openai target', () => {
expect(transformToolChoice('auto', 'openai')).toBe('auto');
expect(transformToolChoice('required', 'openai')).toBe('required');
expect(
transformToolChoice({ type: 'function', function: { name: 'foo' } }, 'openai'),
).toEqual({
type: 'function',
function: { name: 'foo' },
});
});
it('should transform OpenAI format for Anthropic', () => {
expect(transformToolChoice('auto', 'anthropic')).toEqual({ type: 'auto' });
expect(transformToolChoice('none', 'anthropic')).toEqual({ type: 'none' });
expect(transformToolChoice('required', 'anthropic')).toEqual({ type: 'any' });
});
it('should transform OpenAI format for Bedrock', () => {
expect(transformToolChoice('auto', 'bedrock')).toEqual({ auto: {} });
expect(transformToolChoice('required', 'bedrock')).toEqual({ any: {} });
});
it('should transform OpenAI format for Google', () => {
expect(transformToolChoice('auto', 'google')).toEqual({
functionCallingConfig: { mode: 'AUTO' },
});
expect(transformToolChoice('none', 'google')).toEqual({
functionCallingConfig: { mode: 'NONE' },
});
});
it('should pass through for unknown format', () => {
expect(transformToolChoice('auto', 'unknown' as any)).toBe('auto');
});
});
});
describe('OpenAI Tool Format Transformation', () => {
const sampleTools = [
{
type: 'function' as const,
function: {
name: 'get_weather',
description: 'Get the current weather',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] },
},
required: ['location'],
},
},
},
{
type: 'function' as const,
function: {
name: 'search',
description: 'Search the web',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
},
},
},
},
];
describe('isOpenAIToolArray', () => {
it('should return true for OpenAI format tools', () => {
expect(isOpenAIToolArray(sampleTools)).toBe(true);
expect(isOpenAIToolArray([{ type: 'function', function: { name: 'simple_tool' } }])).toBe(
true,
);
});
it('should return false for tools without type: function', () => {
expect(isOpenAIToolArray([{ function: { name: 'test' } }])).toBe(false);
expect(isOpenAIToolArray([{ type: 'other', function: { name: 'test' } }])).toBe(false);
});
it('should return false for tools without function.name', () => {
expect(isOpenAIToolArray([{ type: 'function', function: {} }])).toBe(false);
expect(isOpenAIToolArray([{ type: 'function' }])).toBe(false);
});
it('should return false for empty arrays', () => {
expect(isOpenAIToolArray([])).toBe(false);
});
it('should return false for non-arrays', () => {
expect(isOpenAIToolArray(null)).toBe(false);
expect(isOpenAIToolArray(undefined)).toBe(false);
expect(isOpenAIToolArray('string')).toBe(false);
expect(isOpenAIToolArray({ type: 'function', function: { name: 'tool' } })).toBe(false);
});
it('should return false for other provider formats', () => {
// Anthropic format
const anthropicTools = [{ name: 'test', input_schema: { type: 'object' } }];
expect(isOpenAIToolArray(anthropicTools)).toBe(false);
// Bedrock format
const bedrockTools = [{ toolSpec: { name: 'test' } }];
expect(isOpenAIToolArray(bedrockTools)).toBe(false);
// Google format
const googleTools = [{ functionDeclarations: [{ name: 'test' }] }];
expect(isOpenAIToolArray(googleTools)).toBe(false);
});
});
describe('openaiToolsToAnthropic', () => {
it('should convert to Anthropic format', () => {
const result = openaiToolsToAnthropic(sampleTools);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
name: 'get_weather',
description: 'Get the current weather',
input_schema: sampleTools[0].function.parameters,
});
expect(result[1]).toEqual({
name: 'search',
description: 'Search the web',
input_schema: sampleTools[1].function.parameters,
});
});
it('should provide default input_schema when no parameters', () => {
const result = openaiToolsToAnthropic([
{ type: 'function', function: { name: 'minimal' } },
]);
expect(result[0]).toEqual({
name: 'minimal',
input_schema: { type: 'object', properties: {} },
});
});
it('should handle tools without description', () => {
const result = openaiToolsToAnthropic([
{
type: 'function',
function: { name: 'no_desc', parameters: { type: 'object' } },
},
]);
expect(result[0]).toEqual({
name: 'no_desc',
input_schema: { type: 'object' },
});
});
});
describe('openaiToolsToBedrock', () => {
it('should convert to Bedrock Converse format', () => {
const result = openaiToolsToBedrock(sampleTools);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
toolSpec: {
name: 'get_weather',
description: 'Get the current weather',
inputSchema: {
json: sampleTools[0].function.parameters,
},
},
});
});
it('should provide default inputSchema when no parameters', () => {
const result = openaiToolsToBedrock([{ type: 'function', function: { name: 'minimal' } }]);
expect(result[0]).toEqual({
toolSpec: {
name: 'minimal',
inputSchema: {
json: { type: 'object', properties: {} },
},
},
});
});
});
describe('openaiToolsToGoogle', () => {
it('should convert to Google format with functionDeclarations array', () => {
const result = openaiToolsToGoogle(sampleTools);
expect(result).toHaveLength(1);
expect(result[0].functionDeclarations).toHaveLength(2);
expect(result[0].functionDeclarations[0].name).toBe('get_weather');
expect(result[0].functionDeclarations[1].name).toBe('search');
});
it('should convert types to uppercase', () => {
const result = openaiToolsToGoogle(sampleTools);
const params = result[0].functionDeclarations[0].parameters as Record<string, unknown>;
expect(params.type).toBe('OBJECT');
const props = params.properties as Record<string, Record<string, unknown>>;
expect(props.location.type).toBe('STRING');
});
it('should remove additionalProperties from schema', () => {
const toolsWithAdditionalProps = [
{
type: 'function' as const,
function: {
name: 'test',
parameters: {
type: 'object',
properties: { foo: { type: 'string' } },
additionalProperties: false,
},
},
},
];
const result = openaiToolsToGoogle(toolsWithAdditionalProps);
const params = result[0].functionDeclarations[0].parameters as Record<string, unknown>;
expect(params).not.toHaveProperty('additionalProperties');
});
it('should handle tools without parameters', () => {
const result = openaiToolsToGoogle([{ type: 'function', function: { name: 'minimal' } }]);
expect(result[0].functionDeclarations[0]).toEqual({
name: 'minimal',
});
});
});
describe('transformTools', () => {
it('should pass through non-OpenAI format arrays', () => {
const anthropicTools = [{ name: 'test', input_schema: { type: 'object' } }];
expect(transformTools(anthropicTools, 'anthropic')).toEqual(anthropicTools);
const bedrockTools = [{ toolSpec: { name: 'test' } }];
expect(transformTools(bedrockTools, 'bedrock')).toEqual(bedrockTools);
});
it('should return OpenAI tools unchanged when format is openai', () => {
const result = transformTools(sampleTools, 'openai');
expect(result).toEqual(sampleTools);
});
it('should transform OpenAI tools for Anthropic', () => {
const result = transformTools(sampleTools, 'anthropic') as any[];
expect(result[0].name).toBe('get_weather');
expect(result[0].input_schema).toBeDefined();
});
it('should transform OpenAI tools for Bedrock', () => {
const result = transformTools(sampleTools, 'bedrock') as any[];
expect(result[0].toolSpec.name).toBe('get_weather');
});
it('should transform OpenAI tools for Google', () => {
const result = transformTools(sampleTools, 'google') as any[];
expect(result[0].functionDeclarations[0].name).toBe('get_weather');
});
it('should pass through for unknown format', () => {
expect(transformTools(sampleTools, 'unknown' as any)).toEqual(sampleTools);
});
});
});
});