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

2413 lines
87 KiB
TypeScript

import child_process from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import Stream from 'stream';
import chalk from 'chalk';
import dedent from 'dedent';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearCache, disableCache, enableCache } from '../../src/cache';
import cliState from '../../src/cliState';
import { importModule } from '../../src/esm';
import logger from '../../src/logger';
import { AbliterationProvider } from '../../src/providers/abliteration';
import { AnthropicCompletionProvider } from '../../src/providers/anthropic/completion';
import { AzureChatCompletionProvider } from '../../src/providers/azure/chat';
import { AzureCompletionProvider } from '../../src/providers/azure/completion';
import { AwsBedrockCompletionProvider } from '../../src/providers/bedrock/index';
import { AIStudioEmbeddingProvider } from '../../src/providers/google/ai.studio';
import { VertexChatProvider, VertexEmbeddingProvider } from '../../src/providers/google/vertex';
import { GoogleVideoProvider } from '../../src/providers/google/video';
import {
HuggingfaceFeatureExtractionProvider,
HuggingfaceTextClassificationProvider,
HuggingfaceTextGenerationProvider,
} from '../../src/providers/huggingface';
import {
getProviderIds,
loadApiProvider,
loadApiProviders,
resolveProviderConfigs,
} from '../../src/providers/index';
import { LlamaProvider } from '../../src/providers/llama';
import {
OllamaChatProvider,
OllamaCompletionProvider,
OllamaEmbeddingProvider,
} from '../../src/providers/ollama';
import { OpenAiAssistantProvider } from '../../src/providers/openai/assistant';
import { OpenAiChatCompletionProvider } from '../../src/providers/openai/chat';
import { OpenAICodexAppServerProvider } from '../../src/providers/openai/codex-app-server';
import { OpenAiCompletionProvider } from '../../src/providers/openai/completion';
import { OpenAiEmbeddingProvider } from '../../src/providers/openai/embedding';
import { OpenAiResponsesProvider } from '../../src/providers/openai/responses';
import { PythonProvider } from '../../src/providers/pythonCompletion';
import {
ReplicateImageProvider,
ReplicateModerationProvider,
ReplicateProvider,
} from '../../src/providers/replicate';
import { ScriptCompletionProvider } from '../../src/providers/scriptCompletion';
import { VoyageEmbeddingProvider } from '../../src/providers/voyage';
import { WebhookProvider } from '../../src/providers/webhook';
import RedteamGoatProvider from '../../src/redteam/providers/goat';
import RedteamIterativeProvider from '../../src/redteam/providers/iterative';
import RedteamImageIterativeProvider from '../../src/redteam/providers/iterativeImage';
import RedteamIterativeTreeProvider from '../../src/redteam/providers/iterativeTree';
import { checkProviderApiKeys } from '../../src/util/provider';
import { createMockProvider } from '../factories/provider';
import { mockProcessEnv } from '../util/utils';
import type { ProviderFunction, ProviderOptionsMap } from '../../src/types/index';
vi.mock('proxy-agent', async (importOriginal) => {
return {
...(await importOriginal()),
ProxyAgent: vi.fn().mockImplementation(function () {
return {};
}),
};
});
const mockExecFile = vi.hoisted(() => vi.fn());
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
default: {
...actual,
execFile: mockExecFile,
},
execFile: mockExecFile,
};
});
vi.mock('../../src/esm', async () => ({
...(await vi.importActual('../../src/esm')),
importModule: vi.fn(),
}));
const mockFsReadFileSync = vi.hoisted(() => vi.fn());
const mockFsExistsSync = vi.hoisted(() => vi.fn());
const mockFsMkdirSync = vi.hoisted(() => vi.fn());
const mockFsWriteFileSync = vi.hoisted(() => vi.fn());
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
readFileSync: mockFsReadFileSync,
existsSync: mockFsExistsSync,
mkdirSync: mockFsMkdirSync,
writeFileSync: mockFsWriteFileSync,
},
readFileSync: mockFsReadFileSync,
existsSync: mockFsExistsSync,
mkdirSync: mockFsMkdirSync,
writeFileSync: mockFsWriteFileSync,
};
});
vi.mock('glob', async (importOriginal) => {
return {
...(await importOriginal()),
globSync: vi.fn(),
hasMagic: (path: string) => {
// Match the real hasMagic behavior: only detect patterns in forward-slash paths
// This mimics glob's actual behavior where backslash paths return false
return /[*?[\]{}]/.test(path) && !path.includes('\\');
},
};
});
vi.mock('../../src/database', async (importOriginal) => {
return {
...(await importOriginal()),
getDb: vi.fn(),
};
});
vi.mock('../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
shouldGenerateRemote: vi.fn().mockReturnValue(false),
neverGenerateRemote: vi.fn().mockReturnValue(false),
getRemoteGenerationUrl: vi.fn().mockReturnValue('http://test-url'),
};
});
vi.mock('../../src/providers/websocket');
vi.mock('../../src/globalConfig/accounts', async (importOriginal) => ({
...(await importOriginal()),
isLoggedIntoCloud: vi.fn().mockReturnValue(true),
}));
vi.mock('../../src/globalConfig/cloud', () => {
return {
CLOUD_API_HOST: 'https://api.promptfoo.app',
API_HOST: 'https://api.promptfoo.app',
CloudConfig: vi.fn(),
cloudConfig: {
isEnabled: vi.fn().mockReturnValue(false),
getApiHost: vi.fn().mockReturnValue('https://api.promptfoo.dev'),
getApiKey: vi.fn().mockReturnValue('test-api-key'),
},
};
});
vi.mock('../../src/util/cloud', async () => ({
...(await vi.importActual('../../src/util/cloud')),
getProviderFromCloud: vi.fn(),
validateLinkedTargetId: vi.fn(),
}));
const mockFetch = vi.mocked(vi.fn());
global.fetch = mockFetch;
const defaultMockResponse = {
status: 200,
statusText: 'OK',
headers: {
get: vi.fn().mockReturnValue(null),
entries: vi.fn().mockReturnValue([]),
},
};
beforeEach(() => {
mockExecFile.mockReset();
mockFsReadFileSync.mockReset();
mockFsExistsSync.mockReset();
mockFsMkdirSync.mockReset();
mockFsWriteFileSync.mockReset();
});
describe('call provider apis', () => {
beforeEach(() => {
// Set Azure environment variables for Azure provider tests
mockProcessEnv({ AZURE_API_HOST: 'test.openai.azure.com' });
mockProcessEnv({ AZURE_API_KEY: 'test-api-key' });
});
afterEach(async () => {
vi.clearAllMocks();
await clearCache();
mockProcessEnv({ AZURE_API_HOST: undefined });
mockProcessEnv({ AZURE_API_KEY: undefined });
});
it('AzureOpenAiCompletionProvider callApi', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(
JSON.stringify({
choices: [{ text: 'Test output' }],
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
}),
),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new AzureCompletionProvider('text-davinci-003');
const result = await provider.callApi('Test prompt');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Test output');
expect(result.tokenUsage).toEqual({ total: 10, prompt: 5, completion: 5 });
});
it('AzureOpenAiChatCompletionProvider callApi', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(
JSON.stringify({
choices: [{ message: { content: 'Test output' } }],
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
}),
),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new AzureChatCompletionProvider('gpt-4o-mini');
const result = await provider.callApi(
JSON.stringify([{ role: 'user', content: 'Test prompt' }]),
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Test output');
expect(result.tokenUsage).toEqual({ total: 10, prompt: 5, completion: 5 });
});
it('AzureOpenAiChatCompletionProvider callApi with dataSources', async () => {
const dataSources = [
{
type: 'AzureCognitiveSearch',
endpoint: 'https://search.windows.net',
indexName: 'search-test',
semanticConfiguration: 'default',
queryType: 'vectorSimpleHybrid',
},
];
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(
JSON.stringify({
choices: [
{ message: { role: 'system', content: 'System prompt' } },
{ message: { role: 'user', content: 'Test prompt' } },
{ message: { role: 'assistant', content: 'Test response' } },
],
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
}),
),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new AzureChatCompletionProvider('gpt-4o-mini', {
config: { dataSources },
});
const result = await provider.callApi(
JSON.stringify([
{ role: 'system', content: 'System prompt' },
{ role: 'user', content: 'Test prompt' },
]),
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Test response');
expect(result.tokenUsage).toEqual({ total: 10, prompt: 5, completion: 5 });
});
it('AzureOpenAiChatCompletionProvider callApi with cache disabled', async () => {
disableCache();
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(
JSON.stringify({
choices: [{ message: { content: 'Test output' } }],
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
}),
),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new AzureChatCompletionProvider('gpt-4o-mini');
const result = await provider.callApi(
JSON.stringify([{ role: 'user', content: 'Test prompt' }]),
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Test output');
expect(result.tokenUsage).toEqual({ total: 10, prompt: 5, completion: 5 });
enableCache();
});
it('LlamaProvider callApi', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(
JSON.stringify({
content: 'Test output',
}),
),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new LlamaProvider('llama.cpp');
const result = await provider.callApi('Test prompt');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Test output');
});
it('OllamaCompletionProvider callApi', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi
.fn()
.mockResolvedValue(`{"model":"llama2:13b","created_at":"2023-08-08T21:50:34.898068Z","response":"Gre","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:34.929199Z","response":"at","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:34.959989Z","response":" question","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:34.992117Z","response":"!","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:35.023658Z","response":" The","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:35.0551Z","response":" sky","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:35.086103Z","response":" appears","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:35.117166Z","response":" blue","done":false}
{"model":"llama2:13b","created_at":"2023-08-08T21:50:41.695299Z","done":true,"context":[1,29871,1,13,9314],"total_duration":10411943458,"load_duration":458333,"sample_count":217,"sample_duration":154566000,"prompt_eval_count":11,"prompt_eval_duration":3334582000,"eval_count":216,"eval_duration":6905134000}`),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new OllamaCompletionProvider('llama');
const result = await provider.callApi('Test prompt');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Great question! The sky appears blue');
});
it('OllamaChatProvider callApi', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi
.fn()
.mockResolvedValue(`{"model":"orca-mini","created_at":"2023-12-16T01:46:19.263682972Z","message":{"role":"assistant","content":" Because","images":null},"done":false}
{"model":"orca-mini","created_at":"2023-12-16T01:46:19.275143974Z","message":{"role":"assistant","content":" of","images":null},"done":false}
{"model":"orca-mini","created_at":"2023-12-16T01:46:19.288137727Z","message":{"role":"assistant","content":" Ray","images":null},"done":false}
{"model":"orca-mini","created_at":"2023-12-16T01:46:19.301139709Z","message":{"role":"assistant","content":"leigh","images":null},"done":false}
{"model":"orca-mini","created_at":"2023-12-16T01:46:19.311364699Z","message":{"role":"assistant","content":" scattering","images":null},"done":false}
{"model":"orca-mini","created_at":"2023-12-16T01:46:19.324309782Z","message":{"role":"assistant","content":".","images":null},"done":false}
{"model":"orca-mini","created_at":"2023-12-16T01:46:19.337165395Z","done":true,"total_duration":1486443841,"load_duration":1280794143,"prompt_eval_count":35,"prompt_eval_duration":142384000,"eval_count":6,"eval_duration":61912000}`),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new OllamaChatProvider('llama');
const result = await provider.callApi('Test prompt');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe(' Because of Rayleigh scattering.');
});
it('WebhookProvider callApi', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(
JSON.stringify({
output: 'Test output',
}),
),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new WebhookProvider('http://example.com/webhook');
const result = await provider.callApi('Test prompt');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Test output');
});
describe.each([
['Array format', [{ generated_text: 'Test output' }]], // Array format
['Object format', { generated_text: 'Test output' }], // Object format
])('HuggingfaceTextGenerationProvider callApi with %s', (_format, mockedData) => {
it('returns expected output', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(JSON.stringify(mockedData)),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new HuggingfaceTextGenerationProvider('gpt2');
const result = await provider.callApi('Test prompt');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.output).toBe('Test output');
});
});
it('HuggingfaceFeatureExtractionProvider callEmbeddingApi', async () => {
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(JSON.stringify([0.1, 0.2, 0.3, 0.4, 0.5])),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new HuggingfaceFeatureExtractionProvider('distilbert-base-uncased');
const result = await provider.callEmbeddingApi('Test text');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.embedding).toEqual([0.1, 0.2, 0.3, 0.4, 0.5]);
});
it('HuggingfaceTextClassificationProvider callClassificationApi', async () => {
const mockClassification = [
[
{
label: 'nothate',
score: 0.9,
},
{
label: 'hate',
score: 0.1,
},
],
];
const mockResponse = {
...defaultMockResponse,
text: vi.fn().mockResolvedValue(JSON.stringify(mockClassification)),
};
mockFetch.mockResolvedValue(mockResponse);
const provider = new HuggingfaceTextClassificationProvider('foo');
const result = await provider.callClassificationApi('Test text');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.classification).toEqual({
nothate: 0.9,
hate: 0.1,
});
});
describe.each([
['python rag.py', 'python', ['rag.py']],
['echo "hello world"', 'echo', ['hello world']],
['./path/to/file.py run', './path/to/file.py', ['run']],
['"/Path/To/My File.py"', '/Path/To/My File.py', []],
])('ScriptCompletionProvider callApi with script %s', (script, inputFile, inputArgs) => {
it('returns expected output', async () => {
const mockResponse = 'Test script output';
const mockChildProcess = {
stdout: new Stream.Readable(),
stderr: new Stream.Readable(),
} as child_process.ChildProcess;
mockExecFile.mockImplementation(((_file: any, _args: any, _options: any, callback: any) => {
process.nextTick(
() => callback && callback(null, Buffer.from(mockResponse), Buffer.from('')),
);
return mockChildProcess;
}) as any);
const provider = new ScriptCompletionProvider(script, {
config: {
some_config_val: 42,
},
});
const result = await provider.callApi('Test prompt', {
prompt: {
label: 'Test prompt',
raw: 'Test prompt',
},
vars: {
var1: 'value 1',
var2: 'value 2 "with some double "quotes""',
},
});
expect(result.output).toBe(mockResponse);
expect(mockExecFile).toHaveBeenCalledTimes(1);
expect(mockExecFile).toHaveBeenCalledWith(
expect.stringContaining(inputFile),
expect.arrayContaining(
inputArgs.concat([
'Test prompt',
'{"config":{"some_config_val":42}}',
'{"prompt":{"label":"Test prompt","raw":"Test prompt"},"vars":{"var1":"value 1","var2":"value 2 \\"with some double \\"quotes\\"\\""}}',
]),
),
expect.any(Object),
expect.any(Function),
);
vi.restoreAllMocks();
});
});
});
describe('loadApiProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// Clean up env vars set by #7079 regression tests (explicit list to avoid
// accidentally deleting pre-existing env vars with a broad pattern match)
const keysToClean = [
'TEST_TENANT_7079',
'TEST_APPLICATION_7079',
'TEST_BASE_URL_7079',
'TEST_SERVICE_7079',
'TEST_VERSION_7079',
'TEST_APP_7079_RESOLVED',
'TEST_URL_7079_RESOLVED',
'TEST_APP_7079_HTTP',
'TEST_BASE_7079_HTTP',
'TEST_SVC_7079_HTTP',
'TEST_VER_7079_HTTP',
'TEST_SESSION_7079_HTTP',
'TEST_MISSING_VAR_7079',
];
for (const key of keysToClean) {
mockProcessEnv({ [key]: undefined });
}
});
it('loadApiProvider with yaml filepath', async () => {
const mockYamlContent = dedent`
id: 'openai:gpt-5.1-mini'
config:
key: 'value'`;
const mockReadFileSync = vi.mocked(fs.readFileSync);
mockReadFileSync.mockReturnValue(mockYamlContent);
const provider = await loadApiProvider('file://path/to/mock-provider-file.yaml');
expect(provider.id()).toBe('openai:gpt-5.1-mini');
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringMatching(/path[\\/]to[\\/]mock-provider-file\.yaml/),
'utf8',
);
});
it('loadApiProvider with json filepath', async () => {
const mockJsonContent = `{
"id": "openai:gpt-5.1-mini",
"config": {
"key": "value"
}
}`;
vi.mocked(fs.readFileSync).mockImplementationOnce(function () {
return mockJsonContent;
});
const provider = await loadApiProvider('file://path/to/mock-provider-file.json');
expect(provider.id()).toBe('openai:gpt-5.1-mini');
expect(fs.readFileSync).toHaveBeenCalledWith(
expect.stringMatching(/path[\\/]to[\\/]mock-provider-file\.json/),
'utf8',
);
});
it('loadApiProvider with openai:chat', async () => {
const provider = await loadApiProvider('openai:chat');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
});
it('loadApiProvider with openai:completion', async () => {
const provider = await loadApiProvider('openai:completion');
expect(provider).toBeInstanceOf(OpenAiCompletionProvider);
});
it('loadApiProvider with openai:assistant', async () => {
const provider = await loadApiProvider('openai:assistant:foobar');
expect(provider).toBeInstanceOf(OpenAiAssistantProvider);
});
it('loadApiProvider with openai:chat:modelName', async () => {
const provider = await loadApiProvider('openai:chat:gpt-3.5-turbo');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
});
it('loadApiProvider with openai:completion:modelName', async () => {
const provider = await loadApiProvider('openai:completion:text-davinci-003');
expect(provider).toBeInstanceOf(OpenAiCompletionProvider);
});
it('loadApiProvider with OpenAI finetuned model', async () => {
const provider = await loadApiProvider('openai:chat:ft:gpt-4o-mini:company-name::ID:');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.id()).toBe('openai:ft:gpt-4o-mini:company-name::ID:');
});
it('loadApiProvider with azureopenai:completion:modelName', async () => {
const provider = await loadApiProvider('azureopenai:completion:text-davinci-003');
expect(provider).toBeInstanceOf(AzureCompletionProvider);
});
it('loadApiProvider with azureopenai:chat:modelName', async () => {
const provider = await loadApiProvider('azureopenai:chat:gpt-3.5-turbo');
expect(provider).toBeInstanceOf(AzureChatCompletionProvider);
});
it('loadApiProvider with anthropic:completion', async () => {
const provider = await loadApiProvider('anthropic:completion');
expect(provider).toBeInstanceOf(AnthropicCompletionProvider);
});
it('loadApiProvider with anthropic:completion:modelName', async () => {
const provider = await loadApiProvider('anthropic:completion:claude-1');
expect(provider).toBeInstanceOf(AnthropicCompletionProvider);
});
it('should load Ollama completion provider', async () => {
const provider = await loadApiProvider('ollama:llama3.3:8b');
expect(provider).toBeInstanceOf(OllamaCompletionProvider);
expect(provider.id()).toBe('ollama:completion:llama3.3:8b');
});
it('should load Ollama completion provider with explicit type', async () => {
const provider = await loadApiProvider('ollama:completion:llama3.3:8b');
expect(provider).toBeInstanceOf(OllamaCompletionProvider);
expect(provider.id()).toBe('ollama:completion:llama3.3:8b');
});
it('should load Ollama embedding provider', async () => {
const provider = await loadApiProvider('ollama:embedding:llama3.3:8b');
expect(provider).toBeInstanceOf(OllamaEmbeddingProvider);
});
it('should load Ollama embeddings provider (alias)', async () => {
const provider = await loadApiProvider('ollama:embeddings:llama3.3:8b');
expect(provider).toBeInstanceOf(OllamaEmbeddingProvider);
});
it('should load Ollama chat provider', async () => {
const provider = await loadApiProvider('ollama:chat:llama3.3:8b');
expect(provider).toBeInstanceOf(OllamaChatProvider);
expect(provider.id()).toBe('ollama:chat:llama3.3:8b');
});
it('loadApiProvider with llama:modelName', async () => {
const provider = await loadApiProvider('llama');
expect(provider).toBeInstanceOf(LlamaProvider);
});
it('loadApiProvider with webhook', async () => {
const provider = await loadApiProvider('webhook:http://example.com/webhook');
expect(provider).toBeInstanceOf(WebhookProvider);
});
it('loadApiProvider with n8n keeps webhook URLs out of provider IDs', async () => {
const provider = await loadApiProvider(
'n8n:https://example.com/webhook/private-token?key=secret',
);
expect(provider.id()).toMatch(/^n8n:webhook:[a-f0-9]{12}$/);
expect(provider.id()).not.toContain('private-token');
expect(provider.id()).not.toContain('secret');
});
it('loadApiProvider with huggingface:text-generation', async () => {
const provider = await loadApiProvider('huggingface:text-generation:foobar/baz');
expect(provider).toBeInstanceOf(HuggingfaceTextGenerationProvider);
});
it('loadApiProvider with huggingface:feature-extraction', async () => {
const provider = await loadApiProvider('huggingface:feature-extraction:foobar/baz');
expect(provider).toBeInstanceOf(HuggingfaceFeatureExtractionProvider);
});
it('loadApiProvider with huggingface:text-classification', async () => {
const provider = await loadApiProvider('huggingface:text-classification:foobar/baz');
expect(provider).toBeInstanceOf(HuggingfaceTextClassificationProvider);
});
it('loadApiProvider with hf:text-classification', async () => {
const provider = await loadApiProvider('hf:text-classification:foobar/baz');
expect(provider).toBeInstanceOf(HuggingfaceTextClassificationProvider);
});
it('loadApiProvider with bedrock:completion', async () => {
const provider = await loadApiProvider('bedrock:completion:anthropic.claude-v2:1');
expect(provider).toBeInstanceOf(AwsBedrockCompletionProvider);
});
it('loadApiProvider with openrouter', async () => {
const provider = await loadApiProvider('openrouter:anthropic/claude-opus-4.7');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
// OpenRouter provider now returns id with prefix
expect(provider.id()).toBe('openrouter:anthropic/claude-opus-4.7');
});
it('loadApiProvider with openrouter preserves a custom apiBaseUrl override', async () => {
const provider = await loadApiProvider('openrouter:anthropic/claude-opus-4.7', {
options: {
config: {
apiBaseUrl: 'https://proxy.example.com/openrouter/api/v1',
},
},
});
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.config.apiBaseUrl).toBe('https://proxy.example.com/openrouter/api/v1');
});
it('loadApiProvider with github', async () => {
const provider = await loadApiProvider('github:gpt-4o-mini');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
// Intentionally openai, because it's just a wrapper around openai
expect(provider.id()).toBe('gpt-4o-mini');
});
it('loadApiProvider with perplexity', async () => {
const provider = await loadApiProvider('perplexity:llama-3-sonar-large-32k-online');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.id()).toBe('llama-3-sonar-large-32k-online');
expect(provider.config.apiBaseUrl).toBe('https://api.perplexity.ai');
expect(provider.config.apiKeyEnvar).toBe('PERPLEXITY_API_KEY');
});
it('loadApiProvider with minimax', async () => {
const provider = await loadApiProvider('minimax:MiniMax-M3');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.id()).toBe('minimax:MiniMax-M3');
expect(provider.config.apiBaseUrl).toBe('https://api.minimax.io/v1');
expect(provider.config.apiKeyEnvar).toBe('MINIMAX_API_KEY');
});
it('loadApiProvider with togetherai', async () => {
const provider = await loadApiProvider(
'togetherai:chat:meta/meta-llama/Meta-Llama-3-8B-Instruct',
);
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.id()).toBe('meta/meta-llama/Meta-Llama-3-8B-Instruct');
});
it('loadApiProvider with abliteration', async () => {
const provider = await loadApiProvider('abliteration:abliterated-model');
expect(provider).toBeInstanceOf(AbliterationProvider);
expect(provider.id()).toBe('abliteration:abliterated-model');
expect(provider.config.apiBaseUrl).toBe('https://api.abliteration.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('ABLIT_KEY');
expect(provider.config.showThinking).toBe(false);
});
it('loadApiProvider with abliteration chat format', async () => {
const provider = await loadApiProvider('abliteration:chat:abliterated-model');
expect(provider).toBeInstanceOf(AbliterationProvider);
expect(provider.id()).toBe('abliteration:abliterated-model');
expect(provider.config.apiBaseUrl).toBe('https://api.abliteration.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('ABLIT_KEY');
expect(provider.config.showThinking).toBe(false);
});
it('loadApiProvider rejects malformed abliteration routes', async () => {
await expect(loadApiProvider('abliteration:chat')).rejects.toThrow(
'Abliteration provider requires a model name. Use format: abliteration:<model_name> or abliteration:chat:<model_name>',
);
await expect(loadApiProvider('abliteration:')).rejects.toThrow(
'Abliteration provider requires a model name. Use format: abliteration:<model_name> or abliteration:chat:<model_name>',
);
});
it('loadApiProvider with litellm default (chat)', async () => {
const provider = await loadApiProvider('litellm:gpt-5.1-mini');
expect(provider.id()).toBe('litellm:gpt-5.1-mini');
expect(provider.toString()).toBe('[LiteLLM Provider gpt-5.1-mini]');
expect(provider.config.apiBaseUrl).toBe('http://0.0.0.0:4000');
expect(provider.config.apiKeyEnvar).toBe('LITELLM_API_KEY');
});
it('loadApiProvider with litellm:chat', async () => {
const provider = await loadApiProvider('litellm:chat:gpt-5.1-mini');
expect(provider.id()).toBe('litellm:gpt-5.1-mini');
expect(provider.toString()).toBe('[LiteLLM Provider gpt-5.1-mini]');
});
it('loadApiProvider with litellm:completion', async () => {
const provider = await loadApiProvider('litellm:completion:gpt-3.5-turbo-instruct');
expect(provider.id()).toBe('litellm:completion:gpt-3.5-turbo-instruct');
expect(provider.toString()).toBe('[LiteLLM Provider completion gpt-3.5-turbo-instruct]');
});
it('loadApiProvider with litellm:embedding', async () => {
const provider = await loadApiProvider('litellm:embedding:text-embedding-3-small');
expect(provider.id()).toBe('litellm:embedding:text-embedding-3-small');
expect(provider.toString()).toBe('[LiteLLM Provider embedding text-embedding-3-small]');
expect('callEmbeddingApi' in provider).toBe(true);
});
it('loadApiProvider with litellm:embeddings (alias)', async () => {
const provider = await loadApiProvider('litellm:embeddings:text-embedding-3-small');
expect(provider.id()).toBe('litellm:embedding:text-embedding-3-small');
expect(provider.toString()).toBe('[LiteLLM Provider embedding text-embedding-3-small]');
expect('callEmbeddingApi' in provider).toBe(true);
});
it('loadApiProvider with voyage', async () => {
const provider = await loadApiProvider('voyage:voyage-2');
expect(provider).toBeInstanceOf(VoyageEmbeddingProvider);
expect(provider.id()).toBe('voyage:voyage-2');
});
it('loadApiProvider with vertex:chat', async () => {
const provider = await loadApiProvider('vertex:chat:vertex-chat-model');
expect(provider).toBeInstanceOf(VertexChatProvider);
expect(provider.id()).toBe('vertex:vertex-chat-model');
});
it('loadApiProvider with vertex:embedding', async () => {
const provider = await loadApiProvider('vertex:embedding:vertex-embedding-model');
expect(provider).toBeInstanceOf(VertexEmbeddingProvider);
expect(provider.id()).toBe('vertex:vertex-embedding-model');
});
it('loadApiProvider with vertex:embeddings', async () => {
const provider = await loadApiProvider('vertex:embeddings:vertex-embedding-model');
expect(provider).toBeInstanceOf(VertexEmbeddingProvider);
expect(provider.id()).toBe('vertex:vertex-embedding-model');
});
it('vertex:embedding forwards providerOptions.config to this.config', async () => {
const provider = (await loadApiProvider('vertex:embedding:gemini-embedding-001', {
options: {
config: { autoTruncate: true, region: 'us-west1' },
},
})) as VertexEmbeddingProvider;
expect(provider).toBeInstanceOf(VertexEmbeddingProvider);
expect(provider.config.autoTruncate).toBe(true);
expect(provider.config.region).toBe('us-west1');
});
it('loadApiProvider with google:embedding', async () => {
const provider = await loadApiProvider('google:embedding:gemini-embedding-001');
expect(provider).toBeInstanceOf(AIStudioEmbeddingProvider);
expect(provider.id()).toBe('google:embedding:gemini-embedding-001');
});
it('loadApiProvider with google:embeddings', async () => {
const provider = await loadApiProvider('google:embeddings:gemini-embedding-001');
expect(provider).toBeInstanceOf(AIStudioEmbeddingProvider);
expect(provider.id()).toBe('google:embedding:gemini-embedding-001');
});
it('loadApiProvider with vertex:modelname', async () => {
const provider = await loadApiProvider('vertex:vertex-chat-model');
expect(provider).toBeInstanceOf(VertexChatProvider);
expect(provider.id()).toBe('vertex:vertex-chat-model');
});
it('loadApiProvider with vertex:video:modelname', async () => {
const provider = await loadApiProvider('vertex:video:veo-3.1-generate-preview');
expect(provider).toBeInstanceOf(GoogleVideoProvider);
expect(provider.id()).toBe('vertex:video:veo-3.1-generate-preview');
});
it('loadApiProvider with replicate:modelname', async () => {
const provider = await loadApiProvider('replicate:meta/llama3');
expect(provider).toBeInstanceOf(ReplicateProvider);
expect(provider.id()).toBe('replicate:meta/llama3');
});
it('loadApiProvider with replicate:modelname:version', async () => {
const provider = await loadApiProvider('replicate:meta/llama3:abc123');
expect(provider).toBeInstanceOf(ReplicateProvider);
expect(provider.id()).toBe('replicate:meta/llama3:abc123');
});
it('loadApiProvider with replicate:image', async () => {
const provider = await loadApiProvider('replicate:image:stability-ai/sdxl');
expect(provider).toBeInstanceOf(ReplicateImageProvider);
expect(provider.id()).toBe('replicate:stability-ai/sdxl');
});
it('loadApiProvider with replicate:image:version', async () => {
const provider = await loadApiProvider('replicate:image:stability-ai/sdxl:abc123');
expect(provider).toBeInstanceOf(ReplicateImageProvider);
expect(provider.id()).toBe('replicate:stability-ai/sdxl:abc123');
});
it('loadApiProvider with replicate:moderation', async () => {
const provider = await loadApiProvider('replicate:moderation:foo/bar');
expect(provider).toBeInstanceOf(ReplicateModerationProvider);
expect(provider.id()).toBe('replicate:foo/bar');
});
it('loadApiProvider with replicate:moderation:version', async () => {
const provider = await loadApiProvider('replicate:moderation:foo/bar:abc123');
expect(provider).toBeInstanceOf(ReplicateModerationProvider);
expect(provider.id()).toBe('replicate:foo/bar:abc123');
});
it('loadApiProvider with modelslab:image:modelName', async () => {
const provider = await loadApiProvider('modelslab:image:flux');
expect(provider.id()).toBe('modelslab:image:flux');
});
it('loadApiProvider with modelslab:image: throws for empty model name', async () => {
await expect(loadApiProvider('modelslab:image:')).rejects.toThrow(/Model name is required/);
});
it('loadApiProvider with modelslab:image prefers provider-level env over context env', async () => {
const provider = await loadApiProvider('modelslab:image:flux', {
options: { env: { MODELSLAB_API_KEY: 'provider-key' } },
env: { MODELSLAB_API_KEY: 'context-key' } as any,
});
expect((provider as any).apiKey).toBe('provider-key');
});
it('loadApiProvider with moonshot prefers provider-level env over context env', async () => {
const provider = (await loadApiProvider('moonshot:kimi-k2.6', {
options: { env: { MOONSHOT_API_KEY: 'provider-key' } },
env: { MOONSHOT_API_KEY: 'context-key' },
})) as OpenAiChatCompletionProvider;
expect(provider.getApiKey()).toBe('provider-key');
});
it('loadApiProvider with file://*.py', async () => {
const provider = await loadApiProvider('file://script.py:function_name');
expect(provider).toBeInstanceOf(PythonProvider);
expect(provider.id()).toBe('python:script.py:function_name');
});
it('loadApiProvider with python:*.py', async () => {
const provider = await loadApiProvider('python:script.py');
expect(provider).toBeInstanceOf(PythonProvider);
expect(provider.id()).toBe('python:script.py:default');
});
it('loadApiProvider with promptfoo:redteam:iterative', async () => {
const provider = await loadApiProvider('promptfoo:redteam:iterative', {
options: { config: { injectVar: 'foo' } },
});
expect(provider).toBeInstanceOf(RedteamIterativeProvider);
expect(provider.id()).toBe('promptfoo:redteam:iterative');
});
it('loadApiProvider with promptfoo:redteam:iterative:tree', async () => {
const provider = await loadApiProvider('promptfoo:redteam:iterative:tree', {
options: { config: { injectVar: 'foo' } },
});
expect(provider).toBeInstanceOf(RedteamIterativeTreeProvider);
expect(provider.id()).toBe('promptfoo:redteam:iterative:tree');
});
it('loadApiProvider with promptfoo:redteam:iterative:image', async () => {
const provider = await loadApiProvider('promptfoo:redteam:iterative:image', {
options: {
config: {
injectVar: 'imageUrl',
},
},
});
expect(provider).toBeInstanceOf(RedteamImageIterativeProvider);
expect(provider.id()).toBe('promptfoo:redteam:iterative:image');
});
it('loadApiProvider with promptfoo:redteam:goat', async () => {
const provider = await loadApiProvider('promptfoo:redteam:goat', {
options: { config: { injectVar: 'goal' } },
});
expect(provider).toBeInstanceOf(RedteamGoatProvider);
expect(provider.id()).toBe('promptfoo:redteam:goat');
});
it('loadApiProvider with RawProviderConfig', async () => {
const rawProviderConfig = {
'openai:chat': {
id: 'test',
config: { foo: 'bar' },
},
};
const provider = await loadApiProvider('openai:chat', {
options: rawProviderConfig['openai:chat'],
});
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
});
it('loadApiProviders with ProviderFunction', async () => {
const providerFunction: ProviderFunction & { transform?: (output: string) => string } = async (
prompt,
) => {
return {
output: `Output for ${prompt}`,
tokenUsage: { total: 10, prompt: 5, completion: 5 },
};
};
providerFunction.transform = (output) => output.toUpperCase();
const providers = await loadApiProviders(providerFunction);
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('custom-function');
expect(providers[0].transform).toBe(providerFunction.transform);
const response = await providers[0].callApi('Test prompt');
expect(response.output).toBe('Output for Test prompt');
expect(response.tokenUsage).toEqual({ total: 10, prompt: 5, completion: 5 });
});
it('loadApiProviders with bare ProviderFunction leaves metadata properties unset', async () => {
const bareProviderFunction: ProviderFunction = async (prompt) => ({
output: `Output for ${prompt}`,
});
const providers = await loadApiProviders([bareProviderFunction] as any);
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('custom-function-0');
// Metadata fields should be absent on the resulting ApiProvider (not set to undefined)
// so downstream merges like `config ?? {}` aren't clobbered.
expect('label' in providers[0]).toBe(false);
expect('transform' in providers[0]).toBe(false);
expect('delay' in providers[0]).toBe(false);
expect('inputs' in providers[0]).toBe(false);
expect('config' in providers[0]).toBe(false);
});
it('loadApiProviders with ProviderFunction preserves metadata properties', async () => {
const providerFunction: ProviderFunction & {
label?: string;
transform?: (output: string) => string;
delay?: number;
inputs?: any;
config?: any;
} = async (prompt) => ({ output: `Output for ${prompt}` });
providerFunction.label = 'My Function';
providerFunction.transform = (output) => output.toUpperCase();
providerFunction.delay = 500;
providerFunction.inputs = [{ role: 'user', content: 'hi' }];
providerFunction.config = { custom: 'value' };
const providers = await loadApiProviders([providerFunction] as any);
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('My Function');
expect(providers[0].label).toBe('My Function');
expect(providers[0].transform).toBe(providerFunction.transform);
expect(providers[0].delay).toBe(500);
expect(providers[0].inputs).toBe(providerFunction.inputs);
expect(providers[0].config).toEqual({ custom: 'value' });
});
it('loadApiProviders and getProviderIds agree on the id of a labeled ProviderFunction in an array', async () => {
// Regression guard: the array-branch of loadApiProviders used to hardcode
// `custom-function-${idx}` regardless of `.label`, while getProviderIds and
// the single-function branch honored the label. They only agreed by accident
// (via the label fallback inside `createProviderFromFunction`). Use the
// descriptor-derived id so the two call sites can never drift again.
const providerFunction: ProviderFunction & { label?: string } = async (prompt) => ({
output: `Output for ${prompt}`,
});
providerFunction.label = 'Labeled Function';
const providers = await loadApiProviders([providerFunction, providerFunction] as any);
const providerIds = getProviderIds([providerFunction, providerFunction] as any);
expect(providers.map((p) => p.id())).toEqual(providerIds);
expect(providerIds).toEqual(['Labeled Function', 'Labeled Function']);
});
it('loadApiProviders with ApiProvider object preserves the provider instance', async () => {
const apiProvider = {
id: () => 'custom-api-provider',
callApi: async (prompt: string) => ({ output: `Output for ${prompt}` }),
transform: (output: string | object) => String(output).toUpperCase(),
};
const providers = await loadApiProviders([apiProvider] as any);
expect(providers).toHaveLength(1);
expect(providers[0]).toBe(apiProvider);
expect(providers[0].id()).toBe('custom-api-provider');
expect(providers[0].transform).toBe(apiProvider.transform);
await expect(providers[0].callApi('Test prompt')).resolves.toEqual({
output: 'Output for Test prompt',
});
});
it('loadApiProviders with top-level ApiProvider object preserves the provider instance', async () => {
const apiProvider = {
id: () => 'custom-api-provider',
callApi: async (prompt: string) => ({ output: `Output for ${prompt}` }),
};
const providers = await loadApiProviders(apiProvider);
expect(providers).toHaveLength(1);
expect(providers[0]).toBe(apiProvider);
});
it('loadApiProviders rejects id-only objects instead of treating them as ApiProvider instances', async () => {
await expect(loadApiProviders([{ id: () => 'missing-call-api' }] as any)).rejects.toThrow(
'Invalid provider at index 0',
);
});
it('loadApiProviders with CustomApiProvider', async () => {
const providerPath = 'file://path/to/file.js';
class CustomApiProvider {
id() {
return 'custom-api-provider';
}
async callApi(input: string) {
return { output: `Processed ${input}` };
}
}
vi.mocked(importModule).mockResolvedValue(CustomApiProvider);
const providers = await loadApiProviders(providerPath);
expect(importModule).toHaveBeenCalledWith(path.resolve('path/to/file.js'));
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('custom-api-provider');
const response = await providers[0].callApi('Test input');
expect(response.output).toBe('Processed Test input');
});
it('loadApiProviders with CustomApiProvider, absolute path', async () => {
const providerPath = 'file:///absolute/path/to/file.js';
class CustomApiProvider {
id() {
return 'custom-api-provider';
}
async callApi(input: string) {
return { output: `Processed ${input}` };
}
}
vi.mocked(importModule).mockResolvedValue(CustomApiProvider);
const providers = await loadApiProviders(providerPath);
expect(importModule).toHaveBeenCalledWith('/absolute/path/to/file.js');
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('custom-api-provider');
const response = await providers[0].callApi('Test input');
expect(response.output).toBe('Processed Test input');
});
it('loadApiProviders with RawProviderConfig[]', async () => {
const rawProviderConfigs: ProviderOptionsMap[] = [
{
'openai:chat:abc123': {
config: { foo: 'bar' },
},
},
{
'openai:completion:def456': {
config: { foo: 'bar' },
},
},
{
'anthropic:completion:ghi789': {
config: { foo: 'bar' },
},
},
];
const providers = await loadApiProviders(rawProviderConfigs);
expect(providers).toHaveLength(3);
expect(providers[0]).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(providers[1]).toBeInstanceOf(OpenAiCompletionProvider);
expect(providers[2]).toBeInstanceOf(AnthropicCompletionProvider);
});
it('loadApiProviders uses ProviderOptionsMap keys for loading and nested ids for labels', async () => {
const providers = await loadApiProviders([
{
'openai:responses:gpt-5.4': {
id: 'custom-openai',
label: 'Custom OpenAI',
config: { apiKey: 'test-key' },
},
},
]);
expect(providers).toHaveLength(1);
expect(providers[0]).toBeInstanceOf(OpenAiResponsesProvider);
expect(providers[0].id()).toBe('custom-openai');
expect(providers[0].label).toBe('Custom OpenAI');
});
it('redacts invalid provider config details before formatting load errors', async () => {
const provider = {
config: {
apiKey: 'sk-proj-invalid-provider-secret',
},
headers: {
Authorization: 'Bearer invalid-provider-secret-token',
},
};
let message = '';
try {
await loadApiProviders([provider as any]);
} catch (err) {
message = (err as Error).message;
}
expect(message).toContain('Invalid provider at index 0');
expect(message).toContain('[REDACTED]');
expect(message).not.toContain('sk-proj-invalid-provider-secret');
expect(message).not.toContain('Bearer invalid-provider-secret-token');
});
it('loadApiProvider sets provider.delay', async () => {
const providerOptions = {
id: 'test-delay',
config: {},
delay: 500,
};
const provider = await loadApiProvider('echo', { options: providerOptions });
expect(provider.delay).toBe(500);
});
it('supports templating in provider URL', async () => {
mockProcessEnv({ MY_HOST: 'api.example.com' });
mockProcessEnv({ MY_PORT: '8080' });
const provider = await loadApiProvider('https://{{ env.MY_HOST }}:{{ env.MY_PORT }}/query', {
options: {
config: {
body: {},
},
},
});
expect(provider.id()).toBe('https://api.example.com:8080/query');
mockProcessEnv({ MY_HOST: undefined });
mockProcessEnv({ MY_PORT: undefined });
});
it('supports templating in provider URL with context env overrides', async () => {
const provider = await loadApiProvider('https://{{ env.MY_HOST }}:{{ env.MY_PORT }}/query', {
env: {
MY_HOST: 'api.example.com',
MY_PORT: '8080',
},
options: {
config: {
body: {},
},
},
});
expect(provider.id()).toBe('https://api.example.com:8080/query');
});
it('resolves env templates in provider IDs loaded from file references', async () => {
mockProcessEnv({ TEST_TENANT_7079: 'tenant-a' });
mockProcessEnv({ TEST_APPLICATION_7079: 'myapp' });
mockProcessEnv({ TEST_BASE_URL_7079: 'example.com' });
mockProcessEnv({ TEST_SERVICE_7079: 'users' });
mockProcessEnv({ TEST_VERSION_7079: 'v3' });
const mockYamlContent = dedent`
id: 'https://{{env.TEST_TENANT_7079}}-{{env.TEST_APPLICATION_7079}}.{{env.TEST_BASE_URL_7079}}/api/{{env.TEST_SERVICE_7079}}/{{env.TEST_VERSION_7079}}'
config:
method: 'GET'
`;
mockFsReadFileSync.mockReturnValue(mockYamlContent);
const providers = await loadApiProviders('file://path/to/provider.yaml');
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('https://tenant-a-myapp.example.com/api/users/v3');
mockProcessEnv({ TEST_TENANT_7079: undefined });
mockProcessEnv({ TEST_APPLICATION_7079: undefined });
mockProcessEnv({ TEST_BASE_URL_7079: undefined });
mockProcessEnv({ TEST_SERVICE_7079: undefined });
mockProcessEnv({ TEST_VERSION_7079: undefined });
});
it('preserves undefined env templates in provider IDs loaded from file references', async () => {
mockProcessEnv({ TEST_APPLICATION_7079: 'myapp' });
mockProcessEnv({ TEST_BASE_URL_7079: 'example.com' });
const mockYamlContent = dedent`
id: 'https://{{env.TEST_APPLICATION_7079}}-{{env.TEST_MISSING_VAR_7079}}.{{env.TEST_BASE_URL_7079}}/api'
config:
method: 'GET'
`;
mockFsReadFileSync.mockReturnValue(mockYamlContent);
const providers = await loadApiProviders('file://path/to/provider.yaml');
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('https://myapp-{{env.TEST_MISSING_VAR_7079}}.example.com/api');
mockProcessEnv({ TEST_APPLICATION_7079: undefined });
mockProcessEnv({ TEST_BASE_URL_7079: undefined });
});
it('resolves env templates when file:// providers are pre-resolved via resolveProviderConfigs', async () => {
mockProcessEnv({ TEST_APP_7079_RESOLVED: 'myapp' });
mockProcessEnv({ TEST_URL_7079_RESOLVED: 'example.com' });
const mockYamlContent = dedent`
id: 'https://{{env.TEST_APP_7079_RESOLVED}}.{{env.TEST_URL_7079_RESOLVED}}/api/v1'
config:
method: 'GET'
`;
mockFsReadFileSync.mockReturnValue(mockYamlContent);
// This is the production path: resolveProviderConfigs resolves file:// to raw ProviderOptions,
// then loadApiProviders processes the resolved ProviderOptions (not file:// strings)
const resolved = resolveProviderConfigs(['file://path/to/provider.yaml']);
expect(Array.isArray(resolved)).toBe(true);
const providers = await loadApiProviders(resolved);
expect(providers).toHaveLength(1);
expect(providers[0].id()).toBe('https://myapp.example.com/api/v1');
mockProcessEnv({ TEST_APP_7079_RESOLVED: undefined });
mockProcessEnv({ TEST_URL_7079_RESOLVED: undefined });
});
it('resolves env templates in HTTP provider URL and config from file reference (#7079)', async () => {
mockProcessEnv({ TEST_APP_7079_HTTP: 'myapp' });
mockProcessEnv({ TEST_BASE_7079_HTTP: 'example.com' });
mockProcessEnv({ TEST_SVC_7079_HTTP: 'users' });
mockProcessEnv({ TEST_VER_7079_HTTP: 'v3' });
mockProcessEnv({ TEST_SESSION_7079_HTTP: 'abc123' });
const mockYamlContent = dedent`
id: 'https://{{env.TEST_APP_7079_HTTP}}.{{env.TEST_BASE_7079_HTTP}}/api/{{env.TEST_SVC_7079_HTTP}}/{{env.TEST_VER_7079_HTTP}}'
config:
method: 'GET'
headers:
Cookie: 'SESSION={{env.TEST_SESSION_7079_HTTP}}'
`;
mockFsReadFileSync.mockReturnValue(mockYamlContent);
const provider = await loadApiProvider('file://path/to/provider.yaml');
expect(provider.id()).toBe('https://myapp.example.com/api/users/v3');
expect(provider.config.headers?.Cookie).toBe('SESSION=abc123');
mockProcessEnv({ TEST_APP_7079_HTTP: undefined });
mockProcessEnv({ TEST_BASE_7079_HTTP: undefined });
mockProcessEnv({ TEST_SVC_7079_HTTP: undefined });
mockProcessEnv({ TEST_VER_7079_HTTP: undefined });
mockProcessEnv({ TEST_SESSION_7079_HTTP: undefined });
});
it('lets explicit env overrides win for providers loaded from file references', async () => {
const mockYamlContent = dedent`
id: 'https://{{env.TEST_FILE_PROVIDER_ENV}}.example.com/api'
env:
TEST_FILE_PROVIDER_ENV: 'file-default'
config:
method: 'GET'
headers:
Authorization: 'Bearer {{env.TEST_FILE_PROVIDER_ENV}}'
`;
mockFsReadFileSync.mockReturnValue(mockYamlContent);
const provider = await loadApiProvider('file://path/to/provider.yaml', {
env: {
TEST_FILE_PROVIDER_ENV: 'context-env',
},
options: {
env: {
TEST_FILE_PROVIDER_ENV: 'explicit-env',
},
},
});
expect(provider.id()).toBe('https://explicit-env.example.com/api');
expect(provider.config.headers?.Authorization).toBe('Bearer explicit-env');
});
it('uses provider env overrides when rendering provider config', async () => {
const provider = await loadApiProvider('echo', {
options: {
env: {
MY_API_KEY: 'secret',
},
config: {
apiKey: '{{ env.MY_API_KEY }}',
},
},
});
expect(provider.config.apiKey).toBe('secret');
});
it('passes provider env overrides to provider instances', async () => {
const provider = (await loadApiProvider('openai:chat', {
options: {
env: {
OPENAI_API_KEY: 'override-key',
},
config: {
apiKeyRequired: false,
},
},
})) as OpenAiChatCompletionProvider;
expect(provider.env?.OPENAI_API_KEY).toBe('override-key');
});
it('preserves merged env overrides for Abliteration providers', async () => {
const originalAblitKey = process.env.ABLIT_KEY;
const originalAblitApiBaseUrl = process.env.ABLIT_API_BASE_URL;
mockProcessEnv({ ABLIT_KEY: undefined });
mockProcessEnv({ ABLIT_API_BASE_URL: undefined });
try {
const provider = (await loadApiProvider('abliteration:abliterated-model', {
env: {
ABLIT_API_BASE_URL: 'https://context.example.com/v1',
},
options: {
env: {
ABLIT_KEY: 'provider-key',
},
},
})) as AbliterationProvider;
expect(provider.env?.ABLIT_API_BASE_URL).toBe('https://context.example.com/v1');
expect(provider.env?.ABLIT_KEY).toBe('provider-key');
expect(provider.config.apiBaseUrl).toBe('https://context.example.com/v1');
expect(provider.getApiKey()).toBe('provider-key');
} finally {
if (originalAblitKey === undefined) {
mockProcessEnv({ ABLIT_KEY: undefined });
} else {
mockProcessEnv({ ABLIT_KEY: originalAblitKey });
}
if (originalAblitApiBaseUrl === undefined) {
mockProcessEnv({ ABLIT_API_BASE_URL: undefined });
} else {
mockProcessEnv({ ABLIT_API_BASE_URL: originalAblitApiBaseUrl });
}
}
});
it('uses cliState env values for Abliteration providers loaded without context env', async () => {
const originalConfig = cliState.config;
cliState.config = {
env: {
ABLIT_API_BASE_URL: 'https://cli-state.example.com/v1',
},
};
try {
const provider = (await loadApiProvider('abliteration:abliterated-model', {
options: {
env: {
ABLIT_KEY: 'provider-key',
},
},
})) as AbliterationProvider;
expect(provider.env?.ABLIT_API_BASE_URL).toBeUndefined();
expect(provider.config.apiBaseUrl).toBe('https://cli-state.example.com/v1');
expect(provider.getApiKey()).toBe('provider-key');
} finally {
cliState.config = originalConfig;
}
});
it('passes provider env overrides through the registry to Claude Agent SDK providers', async () => {
mockProcessEnv({ ANTHROPIC_API_KEY: undefined });
mockProcessEnv({ CLAUDE_CODE_USE_VERTEX: undefined });
const provider = await loadApiProvider('anthropic:claude-agent-sdk', {
options: {
env: {
CLAUDE_CODE_USE_VERTEX: 'true',
},
},
});
expect(checkProviderApiKeys([provider]).size).toBe(0);
});
it('loads OpenAI Codex app-server providers with model-in-path config', async () => {
const provider = (await loadApiProvider('openai:codex-app-server:gpt-5.4', {
options: {
env: {
OPENAI_API_KEY: 'override-key',
},
},
})) as OpenAICodexAppServerProvider;
expect(provider).toBeInstanceOf(OpenAICodexAppServerProvider);
expect(provider.id()).toBe('openai:codex-app-server:gpt-5.4');
expect(provider.config.model).toBe('gpt-5.4');
expect(provider.getApiKey()).toBe('override-key');
expect(checkProviderApiKeys([provider]).size).toBe(0);
});
it('preserves OpenAI Codex app-server provider env overrides from object configs', async () => {
const [provider] = (await loadApiProviders([
{
id: 'openai:codex-app-server:gpt-5.4',
env: {
OPENAI_API_KEY: 'override-key',
},
},
])) as OpenAICodexAppServerProvider[];
expect(provider).toBeInstanceOf(OpenAICodexAppServerProvider);
expect(provider.env?.OPENAI_API_KEY).toBe('override-key');
expect(provider.getApiKey()).toBe('override-key');
expect(checkProviderApiKeys([provider]).size).toBe(0);
});
it('merges context env into OpenAI Codex app-server providers', async () => {
const contextOnlyProvider = (await loadApiProvider('openai:codex-app-server', {
env: {
OPENAI_API_KEY: 'context-key',
},
options: {},
})) as OpenAICodexAppServerProvider;
expect(contextOnlyProvider.getApiKey()).toBe('context-key');
const mergedProvider = (await loadApiProvider('openai:codex-app-server', {
env: {
CODEX_API_KEY: 'context-codex-key',
OPENAI_API_KEY: 'context-openai-key',
},
options: {
env: {
OPENAI_API_KEY: 'options-openai-key',
},
},
})) as OpenAICodexAppServerProvider;
expect(mergedProvider.env?.CODEX_API_KEY).toBe('context-codex-key');
expect(mergedProvider.env?.OPENAI_API_KEY).toBe('options-openai-key');
expect(mergedProvider.getApiKey()).toBe('options-openai-key');
});
it('loads OpenAI Codex desktop alias providers', async () => {
const provider = (await loadApiProvider('openai:codex-desktop:gpt-5.4', {
options: {},
})) as OpenAICodexAppServerProvider;
expect(provider).toBeInstanceOf(OpenAICodexAppServerProvider);
expect(provider.id()).toBe('openai:codex-desktop:gpt-5.4');
expect(provider.config.model).toBe('gpt-5.4');
});
it('isolates env overrides between multiple provider loads', async () => {
// First provider with HOST=dev
const devProvider = await loadApiProvider('https://{{ env.HOST }}/v1/api', {
options: {
env: {
HOST: 'dev.example.com',
},
config: {
body: {},
},
},
});
// Second provider with HOST=prod
const prodProvider = await loadApiProvider('https://{{ env.HOST }}/v1/api', {
options: {
env: {
HOST: 'prod.example.com',
},
config: {
body: {},
},
},
});
// Each provider should have its own resolved URL
expect(devProvider.id()).toBe('https://dev.example.com/v1/api');
expect(prodProvider.id()).toBe('https://prod.example.com/v1/api');
});
it('options.env takes precedence over context.env for same keys', async () => {
const provider = await loadApiProvider('https://{{ env.HOST }}:{{ env.PORT }}/query', {
env: {
HOST: 'context-host.com',
PORT: '8080',
},
options: {
env: {
HOST: 'options-host.com', // Should override context.env.HOST
},
config: {
body: {},
},
},
});
// HOST should come from options.env, PORT from context.env
expect(provider.id()).toBe('https://options-host.com:8080/query');
});
it('renders label using env overrides', async () => {
const provider = await loadApiProvider('echo', {
options: {
label: 'API ({{ env.ENVIRONMENT }})',
env: {
ENVIRONMENT: 'staging',
},
},
});
expect(provider.label).toBe('API (staging)');
});
it('loadApiProvider with yaml filepath containing multiple providers', async () => {
const mockYamlContent = dedent`
- id: 'openai:gpt-4o-mini'
config:
key: 'value1'
- id: 'anthropic:messages:claude-3-5-sonnet-20241022'
config:
key: 'value2'`;
const mockReadFileSync = vi.mocked(fs.readFileSync);
mockReadFileSync.mockReturnValue(mockYamlContent);
const providers = await loadApiProviders('file://path/to/mock-providers-file.yaml');
expect(providers).toHaveLength(2);
expect(providers[0].id()).toBe('openai:gpt-4o-mini');
expect(providers[1].id()).toBe('anthropic:messages:claude-3-5-sonnet-20241022');
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringMatching(/path[\\/]to[\\/]mock-providers-file\.yaml/),
'utf8',
);
});
it('loadApiProvider with json filepath containing multiple providers', async () => {
const mockJsonContent = JSON.stringify([
{
id: 'openai:gpt-4o-mini',
config: { key: 'value1' },
},
{
id: 'anthropic:messages:claude-3-5-sonnet-20241022',
config: { key: 'value2' },
},
]);
vi.mocked(fs.readFileSync).mockImplementationOnce(function () {
return mockJsonContent;
});
const providers = await loadApiProviders('file://path/to/mock-providers-file.json');
expect(providers).toHaveLength(2);
expect(providers[0].id()).toBe('openai:gpt-4o-mini');
expect(providers[1].id()).toBe('anthropic:messages:claude-3-5-sonnet-20241022');
});
it('throws an error for unidentified providers', async () => {
const mockError = vi.spyOn(logger, 'error');
const unknownProviderPath = 'unknown:provider';
await expect(loadApiProvider(unknownProviderPath)).rejects.toThrow(
`Could not identify provider: ${chalk.bold(unknownProviderPath)}`,
);
expect(mockError).toHaveBeenCalledWith(
dedent`
Could not identify provider: ${chalk.bold(unknownProviderPath)}.
${chalk.white(dedent`
Please check your configuration and ensure the provider is correctly specified.
For more information on supported providers, visit: `)} ${chalk.cyan('https://promptfoo.dev/docs/providers/')}
`,
);
mockError.mockRestore();
});
it('renders label using Nunjucks', async () => {
mockProcessEnv({ someVariable: 'foo' });
const providerOptions = {
id: 'openai:chat:gpt-4o',
config: {},
label: '{{ env.someVariable }}',
};
const provider = await loadApiProvider('openai:chat:gpt-4o', { options: providerOptions });
expect(provider.label).toBe('foo');
});
it('renders environment variables in provider config while preserving runtime vars', async () => {
mockProcessEnv({ MY_DEPLOYMENT: 'test-deployment' });
mockProcessEnv({ AZURE_ENDPOINT: 'test.openai.azure.com' });
mockProcessEnv({ API_VERSION: '2024-02-15' });
const providerOptions = {
config: {
apiHost: '{{ env.AZURE_ENDPOINT }}',
apiVersion: '{{ env.API_VERSION }}',
// This should be preserved for runtime
body: { message: '{{ vars.userMessage }}' },
},
};
const provider = await loadApiProvider('azure:chat:{{ env.MY_DEPLOYMENT }}', {
options: providerOptions,
});
expect(provider).toBeInstanceOf(AzureChatCompletionProvider);
// Env vars should be rendered
expect((provider as AzureChatCompletionProvider).apiHost).toBe('test.openai.azure.com');
expect((provider as AzureChatCompletionProvider).config.apiVersion).toBe('2024-02-15');
// Vars templates should be preserved
expect((provider as any).config.body).toEqual({
message: '{{ vars.userMessage }}',
});
mockProcessEnv({ MY_DEPLOYMENT: undefined });
mockProcessEnv({ AZURE_ENDPOINT: undefined });
mockProcessEnv({ API_VERSION: undefined });
});
it('loadApiProvider with xai', async () => {
const provider = await loadApiProvider('xai:grok-2');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.id()).toBe('xai:grok-2');
expect(provider.config.apiBaseUrl).toBe('https://api.x.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('XAI_API_KEY');
});
it.each([
['dashscope:chat:qwen-max', 'qwen-max'],
['dashscope:vl:qwen-vl-max', 'qwen-vl-max'],
['alibaba:qwen-plus', 'qwen-plus'],
['alibaba:chat:qwen-max', 'qwen-max'],
['alibaba:vl:qwen-vl-max', 'qwen-vl-max'],
['alicloud:qwen-plus', 'qwen-plus'],
['aliyun:qwen-plus', 'qwen-plus'],
])('loadApiProvider with %s', async (providerId, expectedModelId) => {
const provider = await loadApiProvider(providerId);
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.id()).toBe(expectedModelId);
expect(provider.config.apiBaseUrl).toBe(
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
);
expect(provider.config.apiKeyEnvar).toBe('DASHSCOPE_API_KEY');
});
it('loadApiProvider with alibaba embedding', async () => {
const provider = await loadApiProvider('alibaba:embedding:text-embedding-v3');
expect(provider).toBeInstanceOf(OpenAiEmbeddingProvider);
expect(provider.id()).toBe('text-embedding-v3');
expect(provider.config.apiBaseUrl).toBe(
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
);
expect(provider.config.apiKeyEnvar).toBe('DASHSCOPE_API_KEY');
});
it('loadApiProvider with alibaba unknown model', async () => {
// Unknown models now only warn, they don't throw errors
const provider = await loadApiProvider('alibaba:unknown-model');
expect(provider).toBeInstanceOf(OpenAiChatCompletionProvider);
expect(provider.id()).toBe('unknown-model');
expect(provider.config.apiBaseUrl).toBe(
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
);
expect(provider.config.apiKeyEnvar).toBe('DASHSCOPE_API_KEY');
});
describe('linkedTargetId validation', () => {
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
});
it('should accept valid linkedTargetId', async () => {
const { validateLinkedTargetId } = await import('../../src/util/cloud');
vi.mocked(validateLinkedTargetId).mockResolvedValue();
const mockYamlContent = dedent`
id: 'openai:gpt-5.1-mini'
config:
linkedTargetId: 'promptfoo://provider/12345678-1234-1234-1234-123456789abc'`;
const mockReadFileSync = vi.mocked(fs.readFileSync);
mockReadFileSync.mockReturnValue(mockYamlContent);
const provider = await loadApiProvider('file://path/to/provider.yaml');
expect(provider.id()).toBe('openai:gpt-5.1-mini');
expect(validateLinkedTargetId).toHaveBeenCalledWith(
'promptfoo://provider/12345678-1234-1234-1234-123456789abc',
);
});
it('should throw error when linkedTargetId validation fails', async () => {
const { validateLinkedTargetId } = await import('../../src/util/cloud');
vi.mocked(validateLinkedTargetId).mockRejectedValue(
new Error(
"Target promptfoo://provider/12345678-1234-1234-1234-123456789abc not found in cloud or you don't have access to it",
),
);
const mockYamlContent = dedent`
id: 'openai:gpt-5.1-mini'
config:
linkedTargetId: 'promptfoo://provider/12345678-1234-1234-1234-123456789abc'`;
const mockReadFileSync = vi.mocked(fs.readFileSync);
mockReadFileSync.mockReturnValue(mockYamlContent);
await expect(loadApiProvider('file://path/to/provider.yaml')).rejects.toThrow(
"Target promptfoo://provider/12345678-1234-1234-1234-123456789abc not found in cloud or you don't have access to it",
);
});
it('should accept provider config without linkedTargetId', async () => {
const { validateLinkedTargetId } = await import('../../src/util/cloud');
const mockYamlContent = dedent`
id: 'openai:gpt-5.1-mini'
config:
temperature: 0.7`;
const mockReadFileSync = vi.mocked(fs.readFileSync);
mockReadFileSync.mockReturnValue(mockYamlContent);
const provider = await loadApiProvider('file://path/to/provider.yaml');
expect(provider.id()).toBe('openai:gpt-5.1-mini');
expect(validateLinkedTargetId).not.toHaveBeenCalled();
});
});
});
describe('getProviderIds', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns provider IDs from a file-based config with multiple providers', () => {
const mockYamlContent = dedent`
- id: 'openai:gpt-4o-mini'
config:
key: 'value1'
- id: 'anthropic:messages:claude-3-5-sonnet-20241022'
config:
key: 'value2'`;
const mockReadFileSync = vi.mocked(fs.readFileSync);
mockReadFileSync.mockReturnValue(mockYamlContent);
const providerIds = getProviderIds('file://path/to/providers.yaml');
expect(providerIds).toEqual([
'openai:gpt-4o-mini',
'anthropic:messages:claude-3-5-sonnet-20241022',
]);
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringMatching(/path[\\/]to[\\/]providers\.yaml/),
'utf8',
);
});
it('returns provider ID from a file with a single provider (non-array)', () => {
const mockYamlContent = dedent`
id: 'openai:gpt-4o-mini'
config:
key: 'value1'`;
vi.mocked(fs.readFileSync).mockReturnValue(mockYamlContent);
const providerIds = getProviderIds('file://path/to/single-provider.yaml');
expect(providerIds).toEqual(['openai:gpt-4o-mini']);
});
it('handles .yml extension', () => {
const mockYamlContent = dedent`
id: 'openai:gpt-4o-mini'
config:
key: 'value1'`;
vi.mocked(fs.readFileSync).mockReturnValue(mockYamlContent);
const providerIds = getProviderIds('file://path/to/provider.yml');
expect(providerIds).toEqual(['openai:gpt-4o-mini']);
});
it('handles .json extension', () => {
const mockJsonContent = JSON.stringify({
id: 'openai:gpt-4o-mini',
config: { key: 'value1' },
});
vi.mocked(fs.readFileSync).mockReturnValue(mockJsonContent);
const providerIds = getProviderIds('file://path/to/provider.json');
expect(providerIds).toEqual(['openai:gpt-4o-mini']);
});
it('flattens file-based providers when mixed with inline providers', () => {
const mockYamlContent = dedent`
- id: 'openai:gpt-4o-mini'
config:
key: 'value1'
- id: 'anthropic:messages:claude-3-5-sonnet-20241022'
config:
key: 'value2'`;
vi.mocked(fs.readFileSync).mockReturnValue(mockYamlContent);
const providerIds = getProviderIds(['echo', 'file://path/to/providers.yaml']);
expect(providerIds).toEqual([
'echo',
'openai:gpt-4o-mini',
'anthropic:messages:claude-3-5-sonnet-20241022',
]);
});
it('throws error when provider config is missing id', () => {
const mockYamlContent = dedent`
config:
key: 'value1'`;
vi.mocked(fs.readFileSync).mockReturnValue(mockYamlContent);
expect(() => getProviderIds('file://path/to/provider.yaml')).toThrow(
'Provider config in path/to/provider.yaml must have an id',
);
});
it('returns string provider as-is when not a file reference', () => {
const providerIds = getProviderIds('openai:gpt-4o-mini');
expect(providerIds).toEqual(['openai:gpt-4o-mini']);
});
it('returns custom-function for function provider', () => {
const customFunction = async () => ({ output: 'test' });
const providerIds = getProviderIds(customFunction);
expect(providerIds).toEqual(['custom-function']);
});
it('handles array with function providers', () => {
const labeledFunction = async () => ({ output: 'test' });
labeledFunction.label = 'my-custom-provider';
const unlabeledFunction = async () => ({ output: 'test2' });
const providerIds = getProviderIds(['echo', labeledFunction, unlabeledFunction]);
expect(providerIds).toEqual(['echo', 'my-custom-provider', 'custom-function-2']);
});
it('extracts id from ProviderOptions objects', () => {
const providerIds = getProviderIds([
{ id: 'openai:gpt-4o-mini', config: { temperature: 0.5 } },
{ id: 'anthropic:messages:claude-3-5-sonnet-20241022' },
]);
expect(providerIds).toEqual([
'openai:gpt-4o-mini',
'anthropic:messages:claude-3-5-sonnet-20241022',
]);
});
it('calls id() for ApiProvider objects', () => {
const apiProvider = {
id: () => 'custom-api-provider',
callApi: async () => ({ output: 'test' }),
};
const providerIds = getProviderIds([apiProvider] as any);
expect(providerIds).toEqual(['custom-api-provider']);
});
it('extracts id from ProviderOptionsMap objects', () => {
const providerIds = getProviderIds([
{ 'openai:gpt-4o-mini': { config: { temperature: 0.5 } } },
{ 'anthropic:messages:claude-3-5-sonnet-20241022': { id: 'custom-id' } },
]);
expect(providerIds).toEqual(['openai:gpt-4o-mini', 'custom-id']);
});
it('does not treat option-only provider objects as ProviderOptionsMap objects', () => {
expect(() => getProviderIds([{ config: { temperature: 0.5 } } as any])).toThrow(
'Invalid provider at index 0',
);
expect(() => getProviderIds([{ prompts: ['prompt1'] } as any])).toThrow(
'Invalid provider at index 0',
);
});
it('does not treat multi-key objects as ProviderOptionsMap objects', () => {
expect(() =>
getProviderIds([
{
'openai:gpt-4o-mini': { config: { temperature: 0.5 } },
'anthropic:messages:claude-3-5-sonnet-20241022': { config: { temperature: 0.4 } },
} as any,
]),
).toThrow('Invalid provider at index 0');
});
it('formats invalid provider errors for circular objects', () => {
const provider: any = { config: { temperature: 0.5 } };
provider.self = provider;
expect(() => getProviderIds([provider])).toThrow('Invalid provider at index 0');
});
it('redacts invalid provider config details before formatting id extraction errors', () => {
const provider = {
config: {
apiKey: 'sk-proj-invalid-provider-secret',
},
headers: {
Authorization: 'Bearer invalid-provider-secret-token',
},
};
let message = '';
try {
getProviderIds([provider as any]);
} catch (err) {
message = (err as Error).message;
}
expect(message).toContain('Invalid provider at index 0');
expect(message).toContain('[REDACTED]');
expect(message).not.toContain('sk-proj-invalid-provider-secret');
expect(message).not.toContain('Bearer invalid-provider-secret-token');
});
it('does not treat file:// paths without yaml/yml/json extension as file references', () => {
const providerIds = getProviderIds('file://path/to/provider.js');
expect(providerIds).toEqual(['file://path/to/provider.js']);
});
it('throws error for invalid provider type', () => {
expect(() => getProviderIds(null as any)).toThrow('Invalid providers list');
});
});
describe('resolveProvider', () => {
let mockProviderMap: Record<string, any>;
let mockProvider1: any;
let mockProvider2: any;
beforeEach(async () => {
mockProvider1 = createMockProvider({ id: 'provider-1', label: 'Provider One' });
mockProvider2 = createMockProvider({ id: 'provider-2' });
mockProviderMap = {
'provider-1': mockProvider1,
'Provider One': mockProvider1,
'provider-2': mockProvider2,
};
});
it('should resolve provider by ID from providerMap', async () => {
const { resolveProvider } = await import('../../src/providers');
const result = await resolveProvider('provider-1', mockProviderMap);
expect(result).toBe(mockProvider1);
});
it('should resolve provider by label from providerMap', async () => {
const { resolveProvider } = await import('../../src/providers');
const result = await resolveProvider('Provider One', mockProviderMap);
expect(result).toBe(mockProvider1);
});
it('should throw error for null provider', async () => {
const { resolveProvider } = await import('../../src/providers');
await expect(resolveProvider(null, mockProviderMap)).rejects.toThrow(
'Provider cannot be null or undefined',
);
});
it('should throw error for undefined provider', async () => {
const { resolveProvider } = await import('../../src/providers');
await expect(resolveProvider(undefined, mockProviderMap)).rejects.toThrow(
'Provider cannot be null or undefined',
);
});
it.each([
['number', 123],
['boolean', false],
['bigint', 1n],
['symbol', Symbol('invalid-provider')],
])('should identify invalid %s provider values', async (type, provider) => {
const { resolveProvider } = await import('../../src/providers');
await expect(resolveProvider(provider as any, mockProviderMap)).rejects.toThrow(
`Invalid provider type. Expected a string (provider id), an object with an 'id' field, a ProviderOptionsMap, or a function. Got: ${type}`,
);
});
it('should report invalid arrays through object-shape diagnostics', async () => {
const { resolveProvider } = await import('../../src/providers');
await expect(resolveProvider([], mockProviderMap)).rejects.toThrow(
"Provider object must have an 'id' field or be a ProviderOptionsMap",
);
});
it('should handle function provider', async () => {
const { resolveProvider } = await import('../../src/providers');
const mockFunctionProvider: any = vi.fn(async (prompt: string) => {
return { output: `Response for: ${prompt}` };
});
mockFunctionProvider.label = 'My Custom Provider';
mockFunctionProvider.transform = (output: string) => output.toUpperCase();
mockFunctionProvider.delay = 250;
const result = await resolveProvider(mockFunctionProvider, mockProviderMap);
expect(result).toBeDefined();
expect(typeof result.id).toBe('function');
expect(result.id()).toBe('My Custom Provider');
expect(result.callApi).toBe(mockFunctionProvider);
expect(result.transform).toBe(mockFunctionProvider.transform);
expect(result.delay).toBe(250);
});
it('should handle function provider without label', async () => {
const { resolveProvider } = await import('../../src/providers');
const mockFunctionProvider = vi.fn(async (prompt: string) => {
return { output: `Response for: ${prompt}` };
});
const result = await resolveProvider(mockFunctionProvider, mockProviderMap);
expect(result).toBeDefined();
expect(typeof result.id).toBe('function');
expect(result.id()).toBe('custom-function');
expect(result.callApi).toBe(mockFunctionProvider);
});
it('should handle empty providerMap gracefully', async () => {
const { resolveProvider } = await import('../../src/providers');
// This should fall back to loadApiProvider for a known provider type
// We'll test with 'echo' which is a simple provider type
const result = await resolveProvider('echo', {});
expect(result).toBeDefined();
expect(typeof result.id).toBe('function');
expect(typeof result.callApi).toBe('function');
});
it('should prioritize providerMap over loadApiProvider', async () => {
const { resolveProvider } = await import('../../src/providers');
// Test that 'echo' gets resolved from providerMap instead of loadApiProvider
const mockEchoProvider = createMockProvider({ id: 'echo-from-map' });
const mapWithEcho = {
...mockProviderMap,
echo: mockEchoProvider,
};
const result = await resolveProvider('echo', mapWithEcho);
expect(result).toBe(mockEchoProvider);
expect(result.id()).toBe('echo-from-map');
});
it('should accept basePath in context parameter', async () => {
const { resolveProvider } = await import('../../src/providers');
// This test verifies the function signature accepts basePath
// The echo provider is used since it doesn't need file resolution
const basePath = '/custom/base/path';
const result = await resolveProvider('echo', {}, { basePath });
expect(result).toBeDefined();
expect(typeof result.id).toBe('function');
});
it('should accept both env and basePath in context parameter', async () => {
const { resolveProvider } = await import('../../src/providers');
// This test verifies the function signature accepts both env and basePath
const basePath = '/custom/base/path';
const env = { API_KEY: 'test-key' };
const result = await resolveProvider('echo', {}, { basePath, env });
expect(result).toBeDefined();
expect(typeof result.id).toBe('function');
});
});
describe('resolveProviderConfigs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should preserve string providers as-is', () => {
const result = resolveProviderConfigs(['openai:gpt-4']);
// Non-file string providers are preserved for loadApiProviders to handle
expect(result).toEqual(['openai:gpt-4']);
});
it('should pass through ProviderOptions objects unchanged', () => {
const providerOptions = {
id: 'openai:gpt-4',
prompts: ['prompt1', 'prompt2'],
config: { temperature: 0.7 },
};
const result = resolveProviderConfigs([providerOptions]);
expect(result).toEqual([providerOptions]);
});
it('should preserve ProviderOptionsMap format', () => {
const providerMap = {
'openai:gpt-4': {
prompts: ['prompt1'],
label: 'GPT-4 Model',
},
};
const result = resolveProviderConfigs([providerMap]);
// ProviderOptionsMap is preserved for loadApiProviders to handle
expect(result).toEqual([providerMap]);
});
it('should preserve ProviderOptionsMap with explicit id', () => {
const providerMap = {
'openai:gpt-4': {
id: 'custom-id',
prompts: ['prompt1'],
},
};
const result = resolveProviderConfigs([providerMap]);
// ProviderOptionsMap is preserved for loadApiProviders to handle
expect(result).toEqual([providerMap]);
});
it('should preserve function providers as-is', () => {
const providerFn = (() => Promise.resolve({ output: 'test' })) as ProviderFunction;
const result = resolveProviderConfigs([providerFn]);
// Functions are preserved for loadApiProviders to handle
expect(result).toEqual([providerFn]);
});
it('should preserve function providers with label as-is', () => {
const providerFn = (() => Promise.resolve({ output: 'test' })) as ProviderFunction;
(providerFn as any).label = 'My Custom Function';
const result = resolveProviderConfigs([providerFn]);
// Functions are preserved for loadApiProviders to handle
expect(result).toEqual([providerFn]);
});
it('should resolve file:// YAML references and preserve prompts field', () => {
const yamlContent = `
id: ollama:phi3
label: Phi3 Model
prompts:
- phi3_prompt
config:
temperature: 0.5
`;
mockFsReadFileSync.mockReturnValue(yamlContent);
const basePath = path.join(path.sep, 'test', 'path');
const result = resolveProviderConfigs(['file://./providers/phi3.yaml'], {
basePath,
});
expect(mockFsReadFileSync).toHaveBeenCalledWith(
path.join(basePath, 'providers', 'phi3.yaml'),
'utf8',
);
expect(result).toEqual([
{
id: 'ollama:phi3',
label: 'Phi3 Model',
prompts: ['phi3_prompt'],
config: { temperature: 0.5 },
},
]);
});
it('should resolve file:// with multiple providers in single file', () => {
const yamlContent = `
- id: ollama:phi3
prompts:
- phi3_prompt
- id: ollama:gemma
prompts:
- gemma_prompt
`;
mockFsReadFileSync.mockReturnValue(yamlContent);
const basePath = path.join(path.sep, 'test', 'path');
const result = resolveProviderConfigs(['file://./providers.yaml'], {
basePath,
});
expect(Array.isArray(result)).toBe(true);
expect((result as any[]).length).toBe(2);
expect((result as any[])[0]).toEqual({ id: 'ollama:phi3', prompts: ['phi3_prompt'] });
expect((result as any[])[1]).toEqual({ id: 'ollama:gemma', prompts: ['gemma_prompt'] });
});
it('should handle mixed provider types preserving non-file providers', () => {
const yamlContent = `
id: ollama:phi3
prompts:
- phi3_prompt
`;
mockFsReadFileSync.mockReturnValue(yamlContent);
const basePath = path.join(path.sep, 'test');
const providerOptions = { id: 'anthropic:claude-3', prompts: ['claude_prompt'] };
const result = resolveProviderConfigs(['openai:gpt-4', providerOptions, 'file://./phi3.yaml'], {
basePath,
});
expect(Array.isArray(result)).toBe(true);
expect((result as any[]).length).toBe(3);
// String providers are preserved as-is
expect((result as any[])[0]).toEqual('openai:gpt-4');
// ProviderOptions are preserved as-is
expect((result as any[])[1]).toEqual(providerOptions);
// file:// references are resolved to ProviderOptions
expect((result as any[])[2]).toEqual({ id: 'ollama:phi3', prompts: ['phi3_prompt'] });
});
it('should preserve single string provider (not array)', () => {
const result = resolveProviderConfigs('openai:gpt-4');
// Single non-file string is preserved as-is
expect(result).toEqual('openai:gpt-4');
});
it('should resolve single file:// string provider (not array)', () => {
const yamlContent = `
id: ollama:phi3
prompts:
- phi3_prompt
`;
mockFsReadFileSync.mockReturnValue(yamlContent);
const basePath = path.join(path.sep, 'test');
const result = resolveProviderConfigs('file://./phi3.yaml', { basePath });
// file:// references are resolved to array of ProviderOptions
expect(result).toEqual([{ id: 'ollama:phi3', prompts: ['phi3_prompt'] }]);
});
it('should preserve single function provider (not array)', () => {
const providerFn = (() => Promise.resolve({ output: 'test' })) as ProviderFunction;
const result = resolveProviderConfigs(providerFn);
// Functions are preserved as-is
expect(result).toBe(providerFn);
});
it('should return input as-is for non-array, non-string, non-function input', () => {
const emptyObj = {} as any;
const result = resolveProviderConfigs(emptyObj);
// Non-standard input is returned as-is
expect(result).toBe(emptyObj);
});
it('should handle absolute file paths', () => {
const yamlContent = `
id: ollama:phi3
prompts:
- phi3_prompt
`;
mockFsReadFileSync.mockReturnValue(yamlContent);
const absolutePath = path.join(path.sep, 'absolute', 'path', 'provider.yaml');
const result = resolveProviderConfigs([`file://${absolutePath}`]);
expect(mockFsReadFileSync).toHaveBeenCalledWith(absolutePath, 'utf8');
expect(result).toEqual([{ id: 'ollama:phi3', prompts: ['phi3_prompt'] }]);
});
it('should handle .yml extension', () => {
const yamlContent = `
id: ollama:phi3
prompts:
- phi3_prompt
`;
mockFsReadFileSync.mockReturnValue(yamlContent);
const basePath = path.join(path.sep, 'test');
const result = resolveProviderConfigs(['file://./provider.yml'], { basePath });
expect(mockFsReadFileSync).toHaveBeenCalledWith(path.join(basePath, 'provider.yml'), 'utf8');
expect(result).toEqual([{ id: 'ollama:phi3', prompts: ['phi3_prompt'] }]);
});
it('should handle .json extension', () => {
const jsonContent = JSON.stringify({
id: 'openai:gpt-4',
prompts: ['gpt_prompt'],
});
mockFsReadFileSync.mockReturnValue(jsonContent);
const basePath = path.join(path.sep, 'test');
const result = resolveProviderConfigs(['file://./provider.json'], { basePath });
expect(mockFsReadFileSync).toHaveBeenCalledWith(path.join(basePath, 'provider.json'), 'utf8');
expect(result).toEqual([{ id: 'openai:gpt-4', prompts: ['gpt_prompt'] }]);
});
});