Files
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

664 lines
17 KiB
TypeScript

// Tools and template variable tests: tool_choice handling, transformToolsFormat integration.
import './setup';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache } from '../../../src/cache';
import logger from '../../../src/logger';
import { HttpProvider } from '../../../src/providers/http';
describe('tools and tool_choice template variables', () => {
const mockUrl = 'http://example.com/api';
beforeEach(() => {
vi.clearAllMocks();
});
it('should make tools available as a template variable', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters: { type: 'object', properties: { location: { type: 'string' } } },
},
},
];
await provider.callApi('test prompt', {
vars: {},
prompt: {
raw: 'test prompt',
label: 'test',
config: { tools },
},
});
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
messages: 'test prompt',
tools,
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should make tool_choice available as a template variable', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tool_choice: '{{ tool_choice }}',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const tool_choice = { type: 'function', function: { name: 'get_weather' } };
await provider.callApi('test prompt', {
vars: {},
prompt: {
raw: 'test prompt',
label: 'test',
config: { tool_choice },
},
});
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
messages: 'test prompt',
tool_choice,
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should handle both tools and tool_choice together', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
tool_choice: '{{ tool_choice }}',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const tools = [
{
type: 'function',
function: {
name: 'report_scores',
parameters: { type: 'object', properties: { score: { type: 'integer' } } },
},
},
];
const tool_choice = { type: 'function', function: { name: 'report_scores' } };
await provider.callApi('test prompt', {
vars: {},
prompt: {
raw: 'test prompt',
label: 'test',
config: { tools, tool_choice },
},
});
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
messages: 'test prompt',
tools,
tool_choice,
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should handle undefined tools gracefully', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
// No tools or tool_choice in config
await provider.callApi('test prompt', {
vars: {},
prompt: {
raw: 'test prompt',
label: 'test',
},
});
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
messages: 'test prompt',
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
});
describe('transformToolsFormat integration', () => {
const mockUrl = 'http://example.com/api';
it('should pass through OpenAI tools unchanged when format is openai', async () => {
const openaiTools = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
},
},
];
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
},
tools: openaiTools,
transformToolsFormat: 'openai',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
messages: 'test prompt',
tools: openaiTools,
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should transform OpenAI tools to anthropic format', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
},
tools: [
{
type: 'function',
function: {
name: 'search',
description: 'Search the web',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
},
},
},
},
],
transformToolsFormat: 'anthropic',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
messages: 'test prompt',
tools: [
{
name: 'search',
description: 'Search the web',
input_schema: {
type: 'object',
properties: {
query: { type: 'string' },
},
},
},
],
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should transform tool_choice to openai format', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
tool_choice: '{{ tool_choice }}',
},
tools: [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather',
},
},
],
tool_choice: { type: 'function', function: { name: 'get_weather' } },
transformToolsFormat: 'openai',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
messages: 'test prompt',
tools: [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather',
},
},
],
tool_choice: { type: 'function', function: { name: 'get_weather' } },
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should transform tool_choice mode required to anthropic format', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
tool_choice: '{{ tool_choice }}',
},
tools: [{ type: 'function', function: { name: 'my_tool' } }],
tool_choice: 'required',
transformToolsFormat: 'anthropic',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
body: JSON.stringify({
messages: 'test prompt',
tools: [
{
name: 'my_tool',
input_schema: { type: 'object', properties: {} },
},
],
tool_choice: { type: 'any' },
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should pass through non-OpenAI tools unchanged', async () => {
const anthropicTools = [
{
name: 'existing_tool',
description: 'Already in Anthropic format',
input_schema: { type: 'object' },
},
];
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
},
tools: anthropicTools,
transformToolsFormat: 'anthropic', // Won't transform - not in OpenAI format
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
body: JSON.stringify({
messages: 'test prompt',
tools: anthropicTools, // Unchanged
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should use tools from prompt.config over provider config', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
},
tools: [{ type: 'function', function: { name: 'provider_tool' } }],
transformToolsFormat: 'anthropic',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
// prompt.config.tools should override provider config
await provider.callApi('test prompt', {
vars: {},
prompt: {
raw: 'test prompt',
label: 'test',
config: {
tools: [
{ type: 'function', function: { name: 'prompt_tool', description: 'From prompt' } },
],
},
},
});
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
body: JSON.stringify({
messages: 'test prompt',
tools: [
{
name: 'prompt_tool',
description: 'From prompt',
input_schema: { type: 'object', properties: {} },
},
],
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should warn when tool_choice is set but tools is empty', async () => {
const loggerWarnSpy = vi.spyOn(logger, 'warn');
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tool_choice: '{{ tool_choice }}',
},
tool_choice: 'required',
transformToolsFormat: 'openai',
// No tools configured
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(loggerWarnSpy).toHaveBeenCalled();
expect(loggerWarnSpy.mock.calls[0][0]).toEqual(
expect.stringContaining('tool_choice is set but tools is empty'),
);
loggerWarnSpy.mockRestore();
});
it('should warn when tool_choice is set but tools array is empty', async () => {
const loggerWarnSpy = vi.spyOn(logger, 'warn');
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
tool_choice: '{{ tool_choice }}',
},
tools: [], // Empty array
tool_choice: 'auto',
transformToolsFormat: 'openai',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(loggerWarnSpy).toHaveBeenCalled();
expect(loggerWarnSpy.mock.calls[0][0]).toEqual(
expect.stringContaining('tool_choice is set but tools is empty'),
);
loggerWarnSpy.mockRestore();
});
it('should not warn when both tools and tool_choice are set', async () => {
const loggerWarnSpy = vi.spyOn(logger, 'warn');
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
messages: '{{ prompt }}',
tools: '{{ tools }}',
tool_choice: '{{ tool_choice }}',
},
tools: [{ normalized: true, name: 'my_tool' }],
tool_choice: 'auto',
transformToolsFormat: 'openai',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(
loggerWarnSpy.mock.calls.some(([message]) =>
String(message).includes('tool_choice is set but tools is empty'),
),
).toBe(false);
loggerWarnSpy.mockRestore();
});
});