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
1367 lines
55 KiB
TypeScript
1367 lines
55 KiB
TypeScript
import path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { isFoundationModelProvider } from '../../src/providers/constants';
|
|
import { getProviderFactories, providerMap } from '../../src/providers/registry';
|
|
|
|
import type { LoadApiProviderContext } from '../../src/types/index';
|
|
import type { ProviderOptions } from '../../src/types/providers';
|
|
|
|
vi.mock('../../src/providers/pythonCompletion', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
|
|
PythonProvider: vi.fn().mockImplementation(function () {
|
|
return {
|
|
id: () => 'python:script.py:default',
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/providers/golangCompletion', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
|
|
GolangProvider: vi.fn().mockImplementation(function () {
|
|
return {
|
|
id: () => 'golang:script.go',
|
|
callApi: vi.fn(),
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/providers/scriptCompletion', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
|
|
ScriptCompletionProvider: vi.fn().mockImplementation(function () {
|
|
return {
|
|
id: () => 'exec:script.sh',
|
|
callApi: vi.fn(),
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/redteam/remoteGeneration', async (importOriginal) => {
|
|
const mod = await importOriginal<typeof import('../../src/redteam/remoteGeneration')>();
|
|
return {
|
|
...mod,
|
|
shouldGenerateRemote: vi.fn(() => true),
|
|
neverGenerateRemote: vi.fn(() => false),
|
|
};
|
|
});
|
|
|
|
describe('Provider Registry', () => {
|
|
describe('Provider Factories', () => {
|
|
const mockProviderOptions: ProviderOptions = {
|
|
id: 'test-provider',
|
|
label: 'Test Provider',
|
|
config: {
|
|
basePath: '/test',
|
|
apiKey: 'test-key',
|
|
},
|
|
};
|
|
|
|
const mockContext: LoadApiProviderContext = {
|
|
basePath: '/test',
|
|
options: mockProviderOptions,
|
|
};
|
|
|
|
const registry = {
|
|
create: async (path: string, context?: any) => {
|
|
const factory = providerMap.find((f) => f.test(path));
|
|
if (!factory) {
|
|
throw new Error(`Could not find provider for path: ${path}`);
|
|
}
|
|
return factory.create(path, context?.options || {}, context || mockContext);
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('getProviderFactories boundary contract', () => {
|
|
it('returns the providerMap reference itself for the no-family fast path', async () => {
|
|
// Pin identity (toBe, not toEqual) so an accidental `return [...providerMap]`
|
|
// on the hot path regresses loudly instead of silently doubling the
|
|
// per-lookup allocation for every non-family provider call.
|
|
const factories = await getProviderFactories('openai:gpt-4');
|
|
expect(factories).toBe(providerMap);
|
|
});
|
|
|
|
it('does not mutate providerMap when a redteam family loads factories', async () => {
|
|
// The merged redteam path must return a fresh array so a caller that
|
|
// accidentally mutates the returned factories cannot corrupt the
|
|
// shared module-scoped providerMap.
|
|
const before = providerMap.length;
|
|
const withRedteam = await getProviderFactories('promptfoo:redteam:crescendo');
|
|
expect(withRedteam).not.toBe(providerMap);
|
|
expect(providerMap.length).toBe(before);
|
|
});
|
|
|
|
it('loads redteam factories for a redteam path', async () => {
|
|
const factories = await getProviderFactories('promptfoo:redteam:crescendo');
|
|
|
|
expect(factories.length).toBeGreaterThan(providerMap.length);
|
|
expect(factories.some((f) => f.test('promptfoo:redteam:crescendo'))).toBe(true);
|
|
// Base provider factories are still present
|
|
expect(factories.some((f) => f.test('echo'))).toBe(true);
|
|
});
|
|
|
|
it('loads redteam factories for the agentic:memory-poisoning path', async () => {
|
|
const factories = await getProviderFactories('agentic:memory-poisoning');
|
|
|
|
expect(factories.length).toBeGreaterThan(providerMap.length);
|
|
expect(factories.some((f) => f.test('agentic:memory-poisoning'))).toBe(true);
|
|
});
|
|
|
|
it('resolves consistently under concurrent lookups', async () => {
|
|
// Two concurrent redteam lookups both `await import` the family
|
|
// module. Node's ESM cache should deduplicate the load so every call
|
|
// sees the same factory references and there are no duplicates.
|
|
const [a, b, c] = await Promise.all([
|
|
getProviderFactories('promptfoo:redteam:crescendo'),
|
|
getProviderFactories('promptfoo:redteam:crescendo'),
|
|
getProviderFactories('promptfoo:redteam:crescendo'),
|
|
]);
|
|
const aCres = a.find((f) => f.test('promptfoo:redteam:crescendo'));
|
|
const bCres = b.find((f) => f.test('promptfoo:redteam:crescendo'));
|
|
const cCres = c.find((f) => f.test('promptfoo:redteam:crescendo'));
|
|
expect(aCres).toBeDefined();
|
|
expect(aCres).toBe(bCres);
|
|
expect(bCres).toBe(cCres);
|
|
expect(a.filter((f) => f.test('promptfoo:redteam:crescendo')).length).toBe(1);
|
|
});
|
|
|
|
it.each([
|
|
'bedrock:completion:anthropic.claude-v2',
|
|
'bedrock-agent:agent-id',
|
|
'sagemaker:endpoint-name',
|
|
])('loads AWS factories without mutating providerMap for %s', async (path) => {
|
|
const before = providerMap.length;
|
|
const factories = await getProviderFactories(path);
|
|
|
|
expect(factories).not.toBe(providerMap);
|
|
expect(providerMap.length).toBe(before);
|
|
expect(providerMap.some((factory) => factory.test(path))).toBe(false);
|
|
expect(factories.some((factory) => factory.test(path))).toBe(true);
|
|
});
|
|
|
|
it('resolves the same AWS factory under concurrent lookups', async () => {
|
|
// All lookups should reuse the factory exported by the cached AWS
|
|
// family module instead of allocating one factory per request.
|
|
const path = 'bedrock:completion:anthropic.claude-v2';
|
|
const [a, b, c] = await Promise.all([
|
|
getProviderFactories(path),
|
|
getProviderFactories(path),
|
|
getProviderFactories(path),
|
|
]);
|
|
const aBedrock = a.find((factory) => factory.test(path));
|
|
const bBedrock = b.find((factory) => factory.test(path));
|
|
const cBedrock = c.find((factory) => factory.test(path));
|
|
|
|
expect(aBedrock).toBeDefined();
|
|
expect(aBedrock).toBe(bBedrock);
|
|
expect(bBedrock).toBe(cBedrock);
|
|
expect(a.filter((factory) => factory.test(path)).length).toBe(1);
|
|
});
|
|
|
|
it.each([
|
|
'vertex:chat:gemini-2.5-flash',
|
|
'google:gemini-2.5-flash',
|
|
'palm:chat-bison',
|
|
])('loads Google factories without mutating providerMap for %s', async (path) => {
|
|
const before = providerMap.length;
|
|
const factories = await getProviderFactories(path);
|
|
|
|
expect(factories).not.toBe(providerMap);
|
|
expect(providerMap.length).toBe(before);
|
|
expect(providerMap.some((factory) => factory.test(path))).toBe(false);
|
|
expect(factories.some((factory) => factory.test(path))).toBe(true);
|
|
});
|
|
|
|
it('resolves the same Google factory under concurrent lookups', async () => {
|
|
const path = 'google:gemini-2.5-flash';
|
|
const [a, b, c] = await Promise.all([
|
|
getProviderFactories(path),
|
|
getProviderFactories(path),
|
|
getProviderFactories(path),
|
|
]);
|
|
const aGoogle = a.find((factory) => factory.test(path));
|
|
const bGoogle = b.find((factory) => factory.test(path));
|
|
const cGoogle = c.find((factory) => factory.test(path));
|
|
|
|
expect(aGoogle).toBeDefined();
|
|
expect(aGoogle).toBe(bGoogle);
|
|
expect(bGoogle).toBe(cGoogle);
|
|
expect(a.filter((factory) => factory.test(path)).length).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('should handle echo provider correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('echo'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create('echo', mockProviderOptions, mockContext);
|
|
const expectedId = mockProviderOptions.id || 'echo';
|
|
expect(provider.id()).toBe(expectedId);
|
|
|
|
const result = await provider.callApi('test input');
|
|
expect(result.output).toBe('test input');
|
|
expect(result.raw).toBe('test input');
|
|
expect(result.cost).toBe(0);
|
|
expect(result.isRefusal).toBe(false);
|
|
});
|
|
|
|
it('fails fast for xAI embedding aliases since xAI has no public embeddings API', async () => {
|
|
const factory = providerMap.find((f) => f.test('xai:embedding:v1'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const embeddingAliases = [
|
|
'xai:embedding:v1',
|
|
'xai:embeddings:v1',
|
|
'xai:embedding',
|
|
'xai:embeddings',
|
|
];
|
|
|
|
for (const alias of embeddingAliases) {
|
|
await expect(factory!.create(alias, {}, mockContext)).rejects.toThrow(
|
|
/xAI does not currently expose a public embeddings API/,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('should handle python provider correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('python:script.py'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create('python:script.py', mockProviderOptions, {
|
|
...mockContext,
|
|
basePath: path.resolve(__dirname, '../fixtures'),
|
|
});
|
|
expect(provider).toBeDefined();
|
|
expect(provider.id()).toBe('python:script.py:default');
|
|
});
|
|
|
|
it('should handle huggingface providers correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('huggingface:text-generation:gpt2'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(
|
|
'huggingface:text-generation:gpt2',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(provider).toBeDefined();
|
|
|
|
await expect(
|
|
factory!.create('huggingface:invalid:gpt2', mockProviderOptions, mockContext),
|
|
).rejects.toThrow('Invalid Huggingface provider path');
|
|
});
|
|
|
|
it('should handle atlascloud providers correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('atlascloud:deepseek-v3'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const atlasOptions = {
|
|
...mockProviderOptions,
|
|
id: undefined,
|
|
};
|
|
|
|
const provider = await factory!.create('atlascloud:deepseek-v3', atlasOptions, mockContext);
|
|
expect(provider).toBeDefined();
|
|
expect(provider.id()).toBe('atlascloud:deepseek-v3');
|
|
});
|
|
|
|
it('should handle moonshot providers correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('moonshot:moonshot-v1-8k'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const moonshotOptions: ProviderOptions = {
|
|
...mockProviderOptions,
|
|
id: undefined,
|
|
config: { temperature: 0.42, apiKey: 'moonshot-test-key' },
|
|
};
|
|
const provider = await factory!.create(
|
|
'moonshot:moonshot-v1-8k',
|
|
moonshotOptions,
|
|
mockContext,
|
|
);
|
|
expect(provider).toBeDefined();
|
|
expect(provider.id()).toBe('moonshot:moonshot-v1-8k');
|
|
const config = (provider as any).config;
|
|
expect(config.temperature).toBe(0.42);
|
|
expect(config.apiKey).toBe('moonshot-test-key');
|
|
expect(config.apiBaseUrl).toBe('https://api.moonshot.ai/v1');
|
|
expect(config.apiKeyEnvar).toBe('MOONSHOT_API_KEY');
|
|
});
|
|
|
|
it('should route Moonshot chat prefixes and Kimi defaults correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('moonshot:chat:kimi-k2.6'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const chatProvider = await factory!.create(
|
|
'moonshot:chat:kimi-k2.6',
|
|
{ ...mockProviderOptions, id: undefined },
|
|
mockContext,
|
|
);
|
|
const defaultProvider = await factory!.create(
|
|
'moonshot:',
|
|
{ ...mockProviderOptions, id: undefined },
|
|
mockContext,
|
|
);
|
|
|
|
expect(chatProvider.id()).toBe('moonshot:kimi-k2.6');
|
|
expect(defaultProvider.id()).toBe('moonshot:kimi-k2.6');
|
|
const { body } = await (chatProvider as any).getOpenAiBody('Hello');
|
|
expect(body.temperature).toBeUndefined();
|
|
expect(body.max_tokens).toBeUndefined();
|
|
});
|
|
|
|
it('should handle http/websocket providers correctly', async () => {
|
|
const httpProvider = await registry.create('http://example.com', {
|
|
options: {
|
|
config: {
|
|
url: 'http://example.com',
|
|
body: { prompt: '{{input}}' },
|
|
},
|
|
},
|
|
});
|
|
expect(httpProvider.id()).toBe('http://example.com');
|
|
|
|
const wsProvider = await registry.create('ws://example.com', {
|
|
options: {
|
|
config: {
|
|
url: 'ws://example.com',
|
|
messageTemplate: '{"message": "{{input}}"}',
|
|
body: { prompt: '{{input}}' },
|
|
},
|
|
},
|
|
});
|
|
expect(wsProvider.id()).toBe('ws://example.com');
|
|
});
|
|
|
|
it('should handle n8n providers correctly', async () => {
|
|
const n8nOptions = {
|
|
...mockProviderOptions,
|
|
id: undefined,
|
|
};
|
|
|
|
const factory = providerMap.find((f) => f.test('n8n:https://example.com/webhook/agent'));
|
|
expect(factory).toBeDefined();
|
|
expect(factory).toBe(providerMap.find((f) => f.test('n8n')));
|
|
|
|
const providerFromPath = await factory!.create(
|
|
'n8n:https://example.com/webhook/agent',
|
|
n8nOptions,
|
|
mockContext,
|
|
);
|
|
expect(providerFromPath.id()).toMatch(/^n8n:webhook:[a-f0-9]{12}$/);
|
|
|
|
const providerFromConfig = await factory!.create(
|
|
'n8n',
|
|
{
|
|
...n8nOptions,
|
|
config: { url: 'https://example.com/webhook/config-agent' },
|
|
},
|
|
mockContext,
|
|
);
|
|
expect(providerFromConfig.id()).toMatch(/^n8n:webhook:[a-f0-9]{12}$/);
|
|
|
|
await expect(factory!.create('n8n', n8nOptions, mockContext)).rejects.toThrow(
|
|
'n8n provider requires a webhook URL',
|
|
);
|
|
expect(isFoundationModelProvider('n8n:https://example.com/webhook/agent')).toBe(false);
|
|
});
|
|
|
|
it('dispatches a representative redteam path through getProviderFactories', async () => {
|
|
// Exhaustive per-factory coverage lives in test/redteam/providers/registry.test.ts.
|
|
// This test only smoke-tests the getProviderFactories → redteam family
|
|
// → factory.test → factory.create chain so a regression at the registry
|
|
// boundary (e.g. the family is wired up but never loaded) fails here.
|
|
const path = 'promptfoo:redteam:crescendo';
|
|
const factory = (await getProviderFactories(path)).find((f) => f.test(path));
|
|
expect(factory).toBeDefined();
|
|
|
|
const redteamConfig = {
|
|
...mockProviderOptions,
|
|
config: {
|
|
...mockProviderOptions.config,
|
|
injectVar: 'test',
|
|
maxTurns: 3,
|
|
maxBacktracks: 2,
|
|
redteamProvider: 'test-provider',
|
|
},
|
|
};
|
|
|
|
const provider = await factory!.create(path, redteamConfig, mockContext);
|
|
expect(provider.id()).toBe(path);
|
|
});
|
|
|
|
it('should handle anthropic providers correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('anthropic:messages:claude-3'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Create a version of options without ID for Anthropic tests
|
|
const anthropicOptions = {
|
|
...mockProviderOptions,
|
|
id: undefined,
|
|
};
|
|
|
|
// Test traditional format with messages
|
|
const messagesProvider = await factory!.create(
|
|
'anthropic:messages:claude-3-7-sonnet-20250219',
|
|
anthropicOptions,
|
|
mockContext,
|
|
);
|
|
expect(messagesProvider).toBeDefined();
|
|
expect(messagesProvider.id()).toBe('anthropic:claude-3-7-sonnet-20250219');
|
|
|
|
// Test traditional format with completion
|
|
const completionProvider = await factory!.create(
|
|
'anthropic:completion:claude-2',
|
|
anthropicOptions,
|
|
mockContext,
|
|
);
|
|
expect(completionProvider).toBeDefined();
|
|
expect(completionProvider.id()).toBe('anthropic:claude-2');
|
|
|
|
const shorthandProvider = await factory!.create(
|
|
'anthropic:claude-3-5-sonnet-20241022',
|
|
anthropicOptions,
|
|
mockContext,
|
|
);
|
|
expect(shorthandProvider).toBeDefined();
|
|
expect(shorthandProvider.id()).toBe('anthropic:claude-3-5-sonnet-20241022');
|
|
|
|
for (const model of ['claude-fable-5', 'claude-mythos-5', 'claude-sonnet-5']) {
|
|
const claude5Provider = await factory!.create(
|
|
`anthropic:${model}`,
|
|
anthropicOptions,
|
|
mockContext,
|
|
);
|
|
expect(claude5Provider.id()).toBe(`anthropic:${model}`);
|
|
}
|
|
|
|
// Test error case with invalid model type
|
|
await expect(
|
|
factory!.create('anthropic:invalid:model', mockProviderOptions, mockContext),
|
|
).rejects.toThrow('Unknown Anthropic model type or model name');
|
|
|
|
// Test error case with invalid model name
|
|
await expect(
|
|
factory!.create('anthropic:non-existent-model', mockProviderOptions, mockContext),
|
|
).rejects.toThrow('Unknown Anthropic model type or model name');
|
|
});
|
|
|
|
it('should handle azure providers correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('azure:chat:gpt-4'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const chatProvider = await factory!.create(
|
|
'azure:chat:gpt-4',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(chatProvider).toBeDefined();
|
|
|
|
const assistantProvider = await factory!.create(
|
|
'azure:assistant:asst_123',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(assistantProvider).toBeDefined();
|
|
|
|
const embeddingProvider = await factory!.create(
|
|
'azure:embedding',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(embeddingProvider).toBeDefined();
|
|
|
|
const completionProvider = await factory!.create(
|
|
'azure:completion:davinci',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(completionProvider).toBeDefined();
|
|
|
|
const imageProvider = await factory!.create(
|
|
'azure:image:mai-image-2-5',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(imageProvider).toBeDefined();
|
|
expect(imageProvider.toString()).toBe('[Azure Image Provider mai-image-2-5]');
|
|
|
|
await expect(
|
|
factory!.create('azure:image', mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/Azure image provider requires a deployment name/);
|
|
await expect(
|
|
factory!.create('azure:image:', mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/Azure image provider requires a deployment name/);
|
|
|
|
// Model types without a default deployment must name one in the path. Cover both
|
|
// the missing (`azure:chat`) and empty (`azure:chat:`) third-segment variants.
|
|
for (const prefix of ['azure:chat', 'azure:completion']) {
|
|
await expect(factory!.create(prefix, mockProviderOptions, mockContext)).rejects.toThrow(
|
|
/requires a deployment name/,
|
|
);
|
|
await expect(
|
|
factory!.create(`${prefix}:`, mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/requires a deployment name/);
|
|
}
|
|
await expect(
|
|
factory!.create('azure:assistant', mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/Azure assistant provider requires an assistant ID/);
|
|
await expect(
|
|
factory!.create('azure:assistant:', mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/Azure assistant provider requires an assistant ID/);
|
|
await expect(
|
|
factory!.create('azure:foundry-agent', mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/Azure foundry-agent provider requires an agent ID/);
|
|
await expect(
|
|
factory!.create('azure:foundry-agent:', mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/Azure foundry-agent provider requires an agent ID/);
|
|
|
|
// Types with sensible defaults must still resolve without a deployment segment.
|
|
expect(
|
|
await factory!.create('azure:embedding', mockProviderOptions, mockContext),
|
|
).toBeDefined();
|
|
expect(
|
|
await factory!.create('azure:responses', mockProviderOptions, mockContext),
|
|
).toBeDefined();
|
|
expect(await factory!.create('azure:video', mockProviderOptions, mockContext)).toBeDefined();
|
|
|
|
// MAI image models are Foundry-only; the Azure OpenAI prefix must reject them.
|
|
await expect(
|
|
factory!.create('azureopenai:image:mai-image-2-5', mockProviderOptions, mockContext),
|
|
).rejects.toThrow(/azureopenai:image is not supported/);
|
|
|
|
await expect(
|
|
factory!.create('azure:invalid:model', mockProviderOptions, mockContext),
|
|
).rejects.toThrow('Unknown Azure model type');
|
|
});
|
|
|
|
it('should handle azure:moderation and reject azureopenai:moderation', async () => {
|
|
const factory = providerMap.find((f) => f.test('azure:moderation'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Both prefixes should resolve to the same factory
|
|
const azureOpenAiFactory = providerMap.find((f) => f.test('azureopenai:moderation'));
|
|
expect(azureOpenAiFactory).toBe(factory);
|
|
|
|
const moderationProvider = (await factory!.create(
|
|
'azure:moderation',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
)) as any;
|
|
expect(moderationProvider).toBeDefined();
|
|
expect(moderationProvider.modelName).toBe('text-content-safety');
|
|
|
|
const moderationWithModel = (await factory!.create(
|
|
'azure:moderation:text-content-safety',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
)) as any;
|
|
expect(moderationWithModel).toBeDefined();
|
|
expect(moderationWithModel.modelName).toBe('text-content-safety');
|
|
|
|
// config.deploymentName fallback with valid model
|
|
const moderationFromConfig = (await factory!.create(
|
|
'azure:moderation',
|
|
{
|
|
...mockProviderOptions,
|
|
config: { deploymentName: 'text-content-safety' },
|
|
},
|
|
mockContext,
|
|
)) as any;
|
|
expect(moderationFromConfig.modelName).toBe('text-content-safety');
|
|
|
|
await expect(
|
|
factory!.create('azureopenai:moderation', mockProviderOptions, mockContext),
|
|
).rejects.toThrow('Azure OpenAI does not support moderation');
|
|
|
|
// Unknown model names should be rejected
|
|
await expect(
|
|
factory!.create('azure:moderation:typo-model', mockProviderOptions, mockContext),
|
|
).rejects.toThrow('Unknown Azure moderation model: typo-model');
|
|
});
|
|
|
|
it('should handle bedrock providers correctly', async () => {
|
|
const factories = await getProviderFactories('bedrock:completion:anthropic.claude-v2');
|
|
const factory = factories.find((f) => f.test('bedrock:completion:anthropic.claude-v2'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const completionProvider = await factory!.create(
|
|
'bedrock:completion:anthropic.claude-v2',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(completionProvider.constructor.name).toBe('AwsBedrockCompletionProvider');
|
|
|
|
// Both the plural and singular aliases must resolve to the embedding
|
|
// provider; a reroute to the completion provider is a user-visible break
|
|
// because embeddings expose a different callApi contract.
|
|
for (const embeddingAlias of ['embedding', 'embeddings']) {
|
|
const embeddingProvider = await factory!.create(
|
|
`bedrock:${embeddingAlias}:amazon.titan-embed-text-v1`,
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(embeddingProvider.constructor.name).toBe('AwsBedrockEmbeddingProvider');
|
|
}
|
|
|
|
// Test backwards compatibility: a bare `bedrock:<model>` still resolves to
|
|
// the completion provider.
|
|
const legacyProvider = await factory!.create(
|
|
'bedrock:anthropic.claude-v2',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(legacyProvider.constructor.name).toBe('AwsBedrockCompletionProvider');
|
|
});
|
|
|
|
it('should handle bedrock converse providers correctly', async () => {
|
|
const factories = await getProviderFactories('bedrock:converse:anthropic.claude-v2');
|
|
const factory = factories.find((f) => f.test('bedrock:converse:anthropic.claude-v2'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(
|
|
'bedrock:converse:anthropic.claude-v2',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(provider.constructor.name).toBe('AwsBedrockConverseProvider');
|
|
});
|
|
|
|
it('should handle bedrock mantle chat providers correctly', async () => {
|
|
const path = 'bedrock:mantle:zai.glm-4.6';
|
|
const factories = await getProviderFactories(path);
|
|
const factory = factories.find((f) => f.test(path));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(
|
|
path,
|
|
{ ...mockProviderOptions, config: { apiKey: 'bedrock-key' } },
|
|
mockContext,
|
|
);
|
|
expect(provider.constructor.name).toBe('BedrockMantleChatProvider');
|
|
});
|
|
|
|
it('should handle bedrock-agent providers correctly', async () => {
|
|
const factories = await getProviderFactories('bedrock-agent:agent-id');
|
|
const factory = factories.find((f) => f.test('bedrock-agent:agent-id'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create('bedrock-agent:agent-id', { config: {} }, mockContext);
|
|
expect(provider.constructor.name).toBe('AwsBedrockAgentsProvider');
|
|
expect(provider.id()).toBe('bedrock-agent:agent-id');
|
|
});
|
|
|
|
it('should handle bedrock agents providers correctly', async () => {
|
|
const factories = await getProviderFactories('bedrock:agents:agent-id');
|
|
const factory = factories.find((f) => f.test('bedrock:agents:agent-id'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(
|
|
'bedrock:agents:agent-id',
|
|
{ config: {} },
|
|
mockContext,
|
|
);
|
|
expect(provider.constructor.name).toBe('AwsBedrockAgentsProvider');
|
|
expect(provider.id()).toBe('bedrock-agent:agent-id');
|
|
});
|
|
|
|
it('should handle bedrock knowledge base providers correctly', async () => {
|
|
const factories = await getProviderFactories('bedrock:kb:amazon.titan-text-express-v1');
|
|
const factory = factories.find((f) => f.test('bedrock:kb:amazon.titan-text-express-v1'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(
|
|
'bedrock:kb:amazon.titan-text-express-v1',
|
|
{ config: { knowledgeBaseId: 'knowledge-base-id' } },
|
|
mockContext,
|
|
);
|
|
expect(provider.constructor.name).toBe('AwsBedrockKnowledgeBaseProvider');
|
|
expect(provider.id()).toBe('bedrock:kb:knowledge-base-id');
|
|
});
|
|
|
|
it('should handle bedrock Nova Sonic providers correctly', async () => {
|
|
const factories = await getProviderFactories('bedrock:nova-sonic');
|
|
const factory = factories.find((f) => f.test('bedrock:nova-sonic'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create('bedrock:nova-sonic', { config: {} }, mockContext);
|
|
expect(provider.constructor.name).toBe('NovaSonicProvider');
|
|
});
|
|
|
|
it.each([
|
|
['sagemaker:embedding:endpoint-name', 'SageMakerEmbeddingProvider', {}, undefined],
|
|
['sagemaker:embeddings:endpoint-name', 'SageMakerEmbeddingProvider', {}, undefined],
|
|
['sagemaker:endpoint-name', 'SageMakerCompletionProvider', { modelType: 'custom' }, 'custom'],
|
|
['sagemaker:jumpstart:endpoint-name', 'SageMakerCompletionProvider', {}, 'jumpstart'],
|
|
['sagemaker:openai:endpoint-name', 'SageMakerCompletionProvider', {}, 'openai'],
|
|
['sagemaker:custom:my-jumpstart-endpoint', 'SageMakerCompletionProvider', {}, 'jumpstart'],
|
|
])('should handle %s providers correctly', async (path, expectedProviderName, config, expectedModelType) => {
|
|
const factories = await getProviderFactories(path);
|
|
const factory = factories.find((f) => f.test(path));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(path, { config }, mockContext);
|
|
expect(provider.constructor.name).toBe(expectedProviderName);
|
|
if (expectedModelType) {
|
|
expect(provider).toHaveProperty('modelType', expectedModelType);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'mistral:mistral-large-latest',
|
|
'MistralChatCompletionProvider',
|
|
'mistral:mistral-large-latest',
|
|
],
|
|
['mistral:embedding', 'MistralEmbeddingProvider', 'mistral:embedding:mistral-embed'],
|
|
[
|
|
'mistral:embeddings:mistral-embed',
|
|
'MistralEmbeddingProvider',
|
|
'mistral:embedding:mistral-embed',
|
|
],
|
|
[
|
|
'mistral:embedding:codestral-embed',
|
|
'MistralEmbeddingProvider',
|
|
'mistral:embedding:codestral-embed',
|
|
],
|
|
])('should route %s correctly', async (path, expectedProviderName, expectedId) => {
|
|
const factories = await getProviderFactories(path);
|
|
const factory = factories.find((f) => f.test(path));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(path, { config: {} }, mockContext);
|
|
expect(provider.constructor.name).toBe(expectedProviderName);
|
|
expect(provider.id()).toBe(expectedId);
|
|
});
|
|
|
|
it('should handle bedrock Luma Ray video provider with model version', async () => {
|
|
const factories = await getProviderFactories('bedrock:luma.ray-v2:0');
|
|
const factory = factories.find((f) => f.test('bedrock:luma.ray-v2:0'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Don't pass id in options so the provider uses its default id
|
|
const provider = await factory!.create('bedrock:luma.ray-v2:0', { config: {} }, mockContext);
|
|
expect(provider).toBeDefined();
|
|
// Verify the model name includes the full version (luma.ray-v2:0, not just '0')
|
|
expect(provider.constructor.name).toBe('LumaRayVideoProvider');
|
|
expect(provider.id()).toContain('luma.ray-v2:0');
|
|
});
|
|
|
|
it('should handle bedrock:video:luma.ray format correctly', async () => {
|
|
const factories = await getProviderFactories('bedrock:video:luma.ray-v2:0');
|
|
const factory = factories.find((f) => f.test('bedrock:video:luma.ray-v2:0'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// bedrock:video:luma.ray-v2:0 should route to Luma Ray, not Nova Reel
|
|
const provider = await factory!.create(
|
|
'bedrock:video:luma.ray-v2:0',
|
|
{ config: {} },
|
|
mockContext,
|
|
);
|
|
expect(provider).toBeDefined();
|
|
// Verify it's a Luma Ray provider, not Nova Reel
|
|
expect(provider.constructor.name).toBe('LumaRayVideoProvider');
|
|
expect(provider.id()).toContain('luma.ray-v2:0');
|
|
expect(provider.id()).not.toContain('nova-reel');
|
|
});
|
|
|
|
it('should handle bedrock Nova Reel video provider', async () => {
|
|
const factories = await getProviderFactories('bedrock:video:amazon.nova-reel-v1:1');
|
|
const factory = factories.find((f) => f.test('bedrock:video:amazon.nova-reel-v1:1'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Don't pass id in options so the provider uses its default id
|
|
const provider = await factory!.create(
|
|
'bedrock:video:amazon.nova-reel-v1:1',
|
|
{ config: {} },
|
|
mockContext,
|
|
);
|
|
expect(provider.constructor.name).toBe('NovaReelVideoProvider');
|
|
expect(provider.id()).toContain('nova-reel');
|
|
});
|
|
|
|
it('defaults the Nova Reel model for a bare bedrock:video path', async () => {
|
|
const factories = await getProviderFactories('bedrock:video');
|
|
const factory = factories.find((f) => f.test('bedrock:video'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Empty model segment falls back to the default Nova Reel model id.
|
|
const provider = await factory!.create('bedrock:video', { config: {} }, mockContext);
|
|
expect(provider.constructor.name).toBe('NovaReelVideoProvider');
|
|
expect(provider.id()).toContain('amazon.nova-reel-v1:1');
|
|
});
|
|
|
|
// Regression guard for the AWS-before-script routing precedence (PR #9537).
|
|
// `getProviderFactories` returns factories in first-match dispatch order (see
|
|
// the consumer loop in src/providers/index.ts), and the module-scoped
|
|
// `providerMap` contains a generic JS/TS-file factory whose `test` is a
|
|
// prefix-agnostic suffix match (`isJavascriptFile`). An AWS provider ID whose
|
|
// final segment ends in a JS/TS extension must still route to the AWS family
|
|
// rather than being hijacked by that custom-module loader. Before the family
|
|
// factories were prepended ahead of `providerMap`, `.find()` (and the real
|
|
// consumer) matched the file factory first and tried to `importModule` the
|
|
// literal provider string, throwing a confusing module-resolution error.
|
|
it.each([
|
|
['bedrock:converse:anthropic.claude.js', 'AwsBedrockConverseProvider'],
|
|
['bedrock:completion:anthropic.claude.ts', 'AwsBedrockCompletionProvider'],
|
|
['bedrock-agent:agent.mjs', 'AwsBedrockAgentsProvider'],
|
|
// 3-segment form so the bare-endpoint path does not trip SageMaker's
|
|
// required-modelType check; the point here is the .ts suffix precedence.
|
|
['sagemaker:jumpstart:my-endpoint.ts', 'SageMakerCompletionProvider'],
|
|
])('routes the AWS path %s to the AWS family, not the generic JS-file loader', async (providerPath, expectedClass) => {
|
|
const factories = await getProviderFactories(providerPath);
|
|
// First-match dispatch, exactly how src/providers/index.ts resolves it.
|
|
const factory = factories.find((f) => f.test(providerPath));
|
|
expect(factory).toBeDefined();
|
|
|
|
const provider = await factory!.create(providerPath, { config: {} }, mockContext);
|
|
expect(provider.constructor.name).toBe(expectedClass);
|
|
});
|
|
|
|
it('should handle cloudflare-ai providers correctly', async () => {
|
|
const factory = providerMap.find((f) =>
|
|
f.test('cloudflare-ai:chat:@cf/meta/llama-2-7b-chat-fp16'),
|
|
);
|
|
expect(factory).toBeDefined();
|
|
|
|
// Cloudflare AI requires both accountId and apiKey
|
|
const cloudflareProviderOptions = {
|
|
...mockProviderOptions,
|
|
config: {
|
|
...mockProviderOptions.config,
|
|
accountId: 'test-account-id',
|
|
apiKey: 'test-api-key',
|
|
},
|
|
};
|
|
|
|
const chatProvider = await factory!.create(
|
|
'cloudflare-ai:chat:@cf/meta/llama-2-7b-chat-fp16',
|
|
cloudflareProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(chatProvider).toBeDefined();
|
|
|
|
const embeddingProvider = await factory!.create(
|
|
'cloudflare-ai:embedding:@cf/baai/bge-base-en-v1.5',
|
|
cloudflareProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(embeddingProvider).toBeDefined();
|
|
|
|
const completionProvider = await factory!.create(
|
|
'cloudflare-ai:completion:@cf/meta/llama-2-7b-chat-fp16',
|
|
cloudflareProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(completionProvider).toBeDefined();
|
|
|
|
await expect(
|
|
factory!.create('cloudflare-ai:invalid:model', cloudflareProviderOptions, mockContext),
|
|
).rejects.toThrow('Unknown Cloudflare AI model type');
|
|
});
|
|
|
|
it('should handle ollama providers correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('ollama:llama-3.3'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const defaultProvider = await factory!.create(
|
|
'ollama:llama-3.3',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(defaultProvider).toBeDefined();
|
|
|
|
const chatProvider = await factory!.create(
|
|
'ollama:chat:llama-3.3',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(chatProvider).toBeDefined();
|
|
|
|
const completionProvider = await factory!.create(
|
|
'ollama:completion:llama-3.3',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(completionProvider).toBeDefined();
|
|
|
|
const embeddingProvider = await factory!.create(
|
|
'ollama:embedding:llama-3.3',
|
|
mockProviderOptions,
|
|
mockContext,
|
|
);
|
|
expect(embeddingProvider).toBeDefined();
|
|
});
|
|
|
|
it('should resolve relative paths correctly for file-based providers', async () => {
|
|
// We'll test the path resolution by looking at the provider IDs, which contain the path
|
|
|
|
// Test Golang provider
|
|
const golangFactory = providerMap.find((f) => f.test('golang:script.go'));
|
|
expect(golangFactory).toBeDefined();
|
|
|
|
// These variables would be used in actual implementation tests
|
|
// Adding underscore prefix to mark as intentionally unused
|
|
const _customContext = {
|
|
basePath: '/custom/path',
|
|
};
|
|
|
|
// For relative paths, they should be joined with basePath
|
|
const _relativePath = 'script.go';
|
|
const _expectedRelativePath = path.join('/custom/path', _relativePath);
|
|
|
|
// For absolute paths, they should remain unchanged
|
|
const _absolutePath = path.resolve('/absolute/path/script.go');
|
|
|
|
// Test Python provider with file:// URL
|
|
const pythonFactory = providerMap.find((f) => f.test('file://script.py'));
|
|
expect(pythonFactory).toBeDefined();
|
|
|
|
// Test exec provider
|
|
const execFactory = providerMap.find((f) => f.test('exec:script.sh'));
|
|
expect(execFactory).toBeDefined();
|
|
|
|
// Instead of testing the exact path resolution logic (which involves mocking),
|
|
// we'll verify that the registry factories exist and are configured correctly.
|
|
// The actual path resolution logic is now identical in all three providers,
|
|
// so testing one provider's implementation would effectively test all of them.
|
|
|
|
// For actual end-to-end tests of the path resolution, integration tests would be more
|
|
// appropriate than these unit tests, especially if we need to mock or spy on
|
|
// the provider constructors.
|
|
});
|
|
|
|
it('should preserve absolute paths in file-based providers', async () => {
|
|
// Create a simple integration test that verifies the factory functionality
|
|
// exists but doesn't attempt detailed mocking of the provider internals
|
|
|
|
// Create an absolute path that would pass path.isAbsolute() check
|
|
const absoluteGolangPath = path.resolve('/absolute/path/golang-script.go');
|
|
const absolutePythonPath = path.resolve('/absolute/path/python-script.py');
|
|
const absoluteExecPath = path.resolve('/absolute/path/exec-script.sh');
|
|
|
|
// Find the correct factories
|
|
const golangFactory = providerMap.find((f) => f.test(`golang:${absoluteGolangPath}`));
|
|
const pythonFactory = providerMap.find((f) => f.test(`python:${absolutePythonPath}`));
|
|
const fileFactory = providerMap.find((f) => f.test(`file://${absolutePythonPath}`));
|
|
const execFactory = providerMap.find((f) => f.test(`exec:${absoluteExecPath}`));
|
|
|
|
// Verify factories exist
|
|
expect(golangFactory).toBeDefined();
|
|
expect(pythonFactory).toBeDefined();
|
|
expect(fileFactory).toBeDefined();
|
|
expect(execFactory).toBeDefined();
|
|
|
|
// Note: We're not testing the actual mocked implementations here,
|
|
// just verifying that the factories exist and can be found for absolute paths.
|
|
// The actual path resolution logic (path.isAbsolute check) is identical in all providers
|
|
// and is already covered by the implementation in registry.ts.
|
|
});
|
|
|
|
it('should handle helicone provider correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('helicone:openai/gpt-4o'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Create a version of options without ID for Helicone tests
|
|
const heliconeOptions = {
|
|
...mockProviderOptions,
|
|
id: undefined,
|
|
};
|
|
|
|
const provider = await factory!.create(
|
|
'helicone:openai/gpt-4o',
|
|
heliconeOptions,
|
|
mockContext,
|
|
);
|
|
expect(provider).toBeDefined();
|
|
expect(provider.id()).toBe('helicone-gateway:openai/gpt-4o');
|
|
|
|
// Test with router configuration
|
|
const providerWithRouter = await factory!.create(
|
|
'helicone:anthropic/claude-3-5-sonnet',
|
|
{
|
|
...heliconeOptions,
|
|
config: {
|
|
...heliconeOptions.config,
|
|
router: 'production',
|
|
},
|
|
},
|
|
mockContext,
|
|
);
|
|
expect(providerWithRouter).toBeDefined();
|
|
expect(providerWithRouter.id()).toBe(
|
|
'helicone-gateway:production:anthropic/claude-3-5-sonnet',
|
|
);
|
|
|
|
// Test error case with missing model
|
|
await expect(factory!.create('helicone:', mockProviderOptions, mockContext)).rejects.toThrow(
|
|
'Helicone provider requires a model in format helicone:<provider/model>',
|
|
);
|
|
});
|
|
|
|
it('should handle groq provider correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('groq:openai/gpt-oss-120b'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Use options without id to verify the provider generates its own id
|
|
const groqOptions = { ...mockProviderOptions, id: undefined };
|
|
const provider = await factory!.create('groq:openai/gpt-oss-120b', groqOptions, {
|
|
...mockContext,
|
|
options: groqOptions,
|
|
});
|
|
expect(provider).toBeDefined();
|
|
expect(provider.id()).toBe('groq:openai/gpt-oss-120b');
|
|
|
|
// Test error case with missing model
|
|
await expect(factory!.create('groq:', groqOptions, mockContext)).rejects.toThrow(
|
|
'Invalid groq provider path',
|
|
);
|
|
});
|
|
|
|
it('should handle mlflow-gateway provider correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('mlflow-gateway:my-endpoint'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const options = {
|
|
...mockProviderOptions,
|
|
id: undefined,
|
|
config: { gatewayUrl: 'http://localhost:5000' },
|
|
};
|
|
const provider = await factory!.create('mlflow-gateway:my-endpoint', options, {
|
|
...mockContext,
|
|
options,
|
|
});
|
|
expect(provider).toBeDefined();
|
|
expect(provider.id()).toBe('mlflow-gateway:my-endpoint');
|
|
|
|
await expect(
|
|
factory!.create(
|
|
'mlflow-gateway:',
|
|
{ ...options, config: { gatewayUrl: 'http://localhost:5000' } },
|
|
mockContext,
|
|
),
|
|
).rejects.toThrow('MLflow Gateway endpoint name is required');
|
|
});
|
|
|
|
it('should handle groq:responses provider correctly', async () => {
|
|
// groq:responses: is handled by the same factory as groq:
|
|
const factory = providerMap.find((f) => f.test('groq:responses:openai/gpt-oss-120b'));
|
|
expect(factory).toBeDefined();
|
|
|
|
// Use options without id to verify the provider generates its own id
|
|
const groqOptions = { ...mockProviderOptions, id: undefined };
|
|
const provider = await factory!.create('groq:responses:openai/gpt-oss-120b', groqOptions, {
|
|
...mockContext,
|
|
options: groqOptions,
|
|
});
|
|
expect(provider).toBeDefined();
|
|
expect(provider.id()).toBe('groq:responses:openai/gpt-oss-120b');
|
|
|
|
// Test error case with missing model
|
|
await expect(factory!.create('groq:responses:', groqOptions, mockContext)).rejects.toThrow(
|
|
'Invalid groq:responses provider path',
|
|
);
|
|
});
|
|
|
|
it('should handle nvidia provider correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('nvidia:meta/llama-3.3-70b-instruct'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const nvidiaOptions = { ...mockProviderOptions, id: undefined };
|
|
const provider = await factory!.create(
|
|
'nvidia:meta/llama-3.3-70b-instruct',
|
|
nvidiaOptions,
|
|
mockContext,
|
|
);
|
|
expect(provider.id()).toBe('nvidia:meta/llama-3.3-70b-instruct');
|
|
|
|
// Missing model after the prefix should throw.
|
|
await expect(factory!.create('nvidia:', nvidiaOptions, mockContext)).rejects.toThrow(
|
|
/expected "nvidia:<model>"/,
|
|
);
|
|
await expect(
|
|
factory!.create('nvidia:embedding:nvidia/nv-embed-v1', nvidiaOptions, mockContext),
|
|
).rejects.toThrow(/Unsupported NVIDIA NIM provider subtype "embedding"/);
|
|
});
|
|
|
|
it('should route novita sub-types and reject unknown ones', async () => {
|
|
const factory = providerMap.find((f) => f.test('novita:meta/llama-3.1-8b-instruct'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const novitaOptions = { ...mockProviderOptions, id: undefined };
|
|
|
|
// Shorthand `novita:<model>` resolves to chat.
|
|
const shorthand = await factory!.create(
|
|
'novita:meta/llama-3.1-8b-instruct',
|
|
novitaOptions,
|
|
mockContext,
|
|
);
|
|
expect(shorthand.id()).toBe('novita:chat:meta/llama-3.1-8b-instruct');
|
|
|
|
// Explicit sub-types resolve to their respective providers.
|
|
const chat = await factory!.create(
|
|
'novita:chat:meta/llama-3.1-8b-instruct',
|
|
novitaOptions,
|
|
mockContext,
|
|
);
|
|
expect(chat.id()).toBe('novita:chat:meta/llama-3.1-8b-instruct');
|
|
|
|
const completion = await factory!.create(
|
|
'novita:completion:meta/llama-3.1-8b-instruct',
|
|
novitaOptions,
|
|
mockContext,
|
|
);
|
|
expect(completion.id()).toBe('novita:completion:meta/llama-3.1-8b-instruct');
|
|
|
|
const embedding = await factory!.create(
|
|
'novita:embedding:baai/bge-m3',
|
|
novitaOptions,
|
|
mockContext,
|
|
);
|
|
expect(embedding.id()).toBe('novita:embedding:baai/bge-m3');
|
|
|
|
// Unknown sub-types (image, moderation, typos) fail-fast instead of silently
|
|
// routing to chat — a routing regression flagged in `src/providers/AGENTS.md`.
|
|
await expect(factory!.create('novita:image:foo', novitaOptions, mockContext)).rejects.toThrow(
|
|
/Unknown Novita provider sub-type "image"/,
|
|
);
|
|
await expect(
|
|
factory!.create('novita:moderation:bar', novitaOptions, mockContext),
|
|
).rejects.toThrow(/Unknown Novita provider sub-type "moderation"/);
|
|
|
|
// Missing model after the prefix should throw.
|
|
await expect(factory!.create('novita:', novitaOptions, mockContext)).rejects.toThrow(
|
|
/Novita model name is required/,
|
|
);
|
|
});
|
|
|
|
it('should handle orcarouter provider correctly', async () => {
|
|
const factory = providerMap.find((f) => f.test('orcarouter:openai/gpt-4o'));
|
|
expect(factory).toBeDefined();
|
|
|
|
const orcaOptions = { ...mockProviderOptions, id: undefined };
|
|
const provider = await factory!.create('orcarouter:openai/gpt-4o', orcaOptions, {
|
|
...mockContext,
|
|
options: orcaOptions,
|
|
});
|
|
expect(provider.id()).toBe('orcarouter:openai/gpt-4o');
|
|
|
|
const autoProvider = await factory!.create(
|
|
'orcarouter:orcarouter/auto',
|
|
orcaOptions,
|
|
mockContext,
|
|
);
|
|
expect(autoProvider.id()).toBe('orcarouter:orcarouter/auto');
|
|
});
|
|
});
|
|
|
|
// Kept at the very end of the file because it uses vi.doMock + resetModules
|
|
// to simulate a broken redteam family dynamic import. Running last avoids
|
|
// polluting earlier tests that share the original module graph.
|
|
describe('getProviderFactories family load error wrapping', () => {
|
|
afterEach(() => {
|
|
vi.doUnmock('../../src/redteam/providers/registry');
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('wraps family factories() rejections with the requested provider path and preserves cause', async () => {
|
|
// vi.doMock factory throws are caught by vitest and rewrapped with its
|
|
// own diagnostic message, which would lose the cause identity the
|
|
// wrapper is trying to preserve. Defining `redteamProviderFactories`
|
|
// as a throwing getter lets the import succeed while the destructure
|
|
// inside `family.factories()` triggers the throw — which is the
|
|
// realistic failure shape (module loads, export access fails) and
|
|
// round-trips cleanly through the async rejection.
|
|
const cause = new Error('simulated registry load failure');
|
|
vi.doMock('../../src/redteam/providers/registry', () => ({
|
|
get redteamProviderFactories() {
|
|
throw cause;
|
|
},
|
|
}));
|
|
vi.resetModules();
|
|
const { getProviderFactories: reloadedGetProviderFactories } = await import(
|
|
'../../src/providers/registry'
|
|
);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await reloadedGetProviderFactories('promptfoo:redteam:crescendo');
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect((caught as Error).message).toContain(
|
|
"Failed to load provider family for 'promptfoo:redteam:crescendo'",
|
|
);
|
|
expect((caught as Error).message).toContain('simulated registry load failure');
|
|
expect((caught as Error).cause).toBe(cause);
|
|
});
|
|
});
|
|
|
|
describe('google: prefix routing', () => {
|
|
// Empty options so the provider computes its own id() rather than using
|
|
// a caller-supplied override.
|
|
const bareOptions: ProviderOptions = { config: {} };
|
|
const bareContext: LoadApiProviderContext = {
|
|
basePath: '/test',
|
|
options: bareOptions,
|
|
};
|
|
|
|
it.each([
|
|
[
|
|
'google:live:gemini-live-2.5-flash-preview',
|
|
async () => (await import('../../src/providers/google/live')).GoogleLiveProvider,
|
|
],
|
|
[
|
|
'google:image:imagen-3.0-generate-002',
|
|
async () => (await import('../../src/providers/google/image')).GoogleImageProvider,
|
|
],
|
|
[
|
|
'google:video:veo-3.1-generate-preview',
|
|
async () => (await import('../../src/providers/google/video')).GoogleVideoProvider,
|
|
],
|
|
[
|
|
'google:gemini-2.5-flash-image',
|
|
async () => (await import('../../src/providers/google/gemini-image')).GeminiImageProvider,
|
|
],
|
|
// Nano Banana 2 / 2 Lite / Pro GA: bare google:<model> routes dispatch on the '-image' substring.
|
|
[
|
|
'google:gemini-3.1-flash-lite-image',
|
|
async () => (await import('../../src/providers/google/gemini-image')).GeminiImageProvider,
|
|
],
|
|
[
|
|
'google:gemini-3.1-flash-image',
|
|
async () => (await import('../../src/providers/google/gemini-image')).GeminiImageProvider,
|
|
],
|
|
[
|
|
'google:gemini-3-pro-image',
|
|
async () => (await import('../../src/providers/google/gemini-image')).GeminiImageProvider,
|
|
],
|
|
// Bare google:<model> default chat route (no service-type segment).
|
|
[
|
|
'google:gemini-2.5-flash',
|
|
async () => (await import('../../src/providers/google/ai.studio')).AIStudioChatProvider,
|
|
],
|
|
[
|
|
'palm:chat-bison',
|
|
async () => (await import('../../src/providers/google/ai.studio')).AIStudioChatProvider,
|
|
],
|
|
[
|
|
'vertex:chat:gemini-2.5-flash',
|
|
async () => (await import('../../src/providers/google/vertex')).VertexChatProvider,
|
|
],
|
|
// Bare vertex:<model> default route exercises the splits.slice(1) chat fallback
|
|
// (distinct from the vertex:chat: branch, which slices from index 2).
|
|
[
|
|
'vertex:gemini-2.5-flash',
|
|
async () => (await import('../../src/providers/google/vertex')).VertexChatProvider,
|
|
],
|
|
[
|
|
'vertex:embedding:gemini-embedding-001',
|
|
async () => (await import('../../src/providers/google/vertex')).VertexEmbeddingProvider,
|
|
],
|
|
// Plural `embeddings` alias must route to the same Vertex embedding provider.
|
|
[
|
|
'vertex:embeddings:gemini-embedding-001',
|
|
async () => (await import('../../src/providers/google/vertex')).VertexEmbeddingProvider,
|
|
],
|
|
[
|
|
'vertex:video:veo-3.1-generate-preview',
|
|
async () => (await import('../../src/providers/google/video')).GoogleVideoProvider,
|
|
],
|
|
] as const)('routes %s to the expected provider class', async (providerPath, loadExpectedProvider) => {
|
|
const factory = (await getProviderFactories(providerPath)).find((f) => f.test(providerPath));
|
|
expect(factory).toBeDefined();
|
|
const provider = await factory!.create(providerPath, bareOptions, bareContext);
|
|
const ExpectedProvider = await loadExpectedProvider();
|
|
expect(provider).toBeInstanceOf(ExpectedProvider);
|
|
});
|
|
|
|
it('applies vertexai config and provider id for vertex:video routes', async () => {
|
|
const providerPath = 'vertex:video:veo-3.1-generate-preview';
|
|
const factory = (await getProviderFactories(providerPath)).find((f) => f.test(providerPath));
|
|
expect(factory).toBeDefined();
|
|
const provider = await factory!.create(providerPath, bareOptions, bareContext);
|
|
expect((provider as any).config?.vertexai).toBe(true);
|
|
expect(provider.id()).toBe(providerPath);
|
|
});
|
|
|
|
it('applies provider id but omits vertexai config for google:video routes', async () => {
|
|
const providerPath = 'google:video:veo-3.1-generate-preview';
|
|
const factory = (await getProviderFactories(providerPath)).find((f) => f.test(providerPath));
|
|
expect(factory).toBeDefined();
|
|
const provider = await factory!.create(providerPath, bareOptions, bareContext);
|
|
// Unlike the vertex:video branch, the google:video branch must not inject vertexai.
|
|
expect((provider as any).config?.vertexai).toBeUndefined();
|
|
expect(provider.id()).toBe(providerPath);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'google:custom-model.ts',
|
|
async () => (await import('../../src/providers/google/ai.studio')).AIStudioChatProvider,
|
|
],
|
|
[
|
|
'palm:chat-bison.js',
|
|
async () => (await import('../../src/providers/google/ai.studio')).AIStudioChatProvider,
|
|
],
|
|
[
|
|
'vertex:chat:custom-model.mjs',
|
|
async () => (await import('../../src/providers/google/vertex')).VertexChatProvider,
|
|
],
|
|
] as const)('routes script-like id %s to the expected provider class', async (providerPath, loadExpectedProvider) => {
|
|
const factory = (await getProviderFactories(providerPath)).find((f) => f.test(providerPath));
|
|
expect(factory).toBeDefined();
|
|
const provider = await factory!.create(providerPath, bareOptions, bareContext);
|
|
const ExpectedProvider = await loadExpectedProvider();
|
|
expect(provider).toBeInstanceOf(ExpectedProvider);
|
|
});
|
|
|
|
it.each([
|
|
['google:embedding:gemini-embedding-001', 'google:embedding:gemini-embedding-001'],
|
|
['google:embeddings:gemini-embedding-001', 'google:embedding:gemini-embedding-001'],
|
|
['palm:embedding:gemini-embedding-001', 'google:embedding:gemini-embedding-001'],
|
|
])('routes %s to the AI Studio embedding provider (id %s)', async (providerPath, expectedId) => {
|
|
const factory = (await getProviderFactories(providerPath)).find((f) => f.test(providerPath));
|
|
expect(factory).toBeDefined();
|
|
const provider = await factory!.create(providerPath, bareOptions, bareContext);
|
|
expect(provider.id()).toBe(expectedId);
|
|
expect(typeof (provider as any).callEmbeddingApi).toBe('function');
|
|
});
|
|
|
|
it('does not route google:<model> (chat) to the embedding provider', async () => {
|
|
const factory = (await getProviderFactories('google:gemini-2.5-flash')).find((f) =>
|
|
f.test('google:gemini-2.5-flash'),
|
|
);
|
|
expect(factory).toBeDefined();
|
|
const provider = await factory!.create('google:gemini-2.5-flash', bareOptions, bareContext);
|
|
expect(typeof (provider as any).callEmbeddingApi).not.toBe('function');
|
|
expect(provider.id()).toContain('gemini-2.5-flash');
|
|
});
|
|
|
|
it.each([
|
|
'google:embedding:',
|
|
'google:embeddings:',
|
|
'palm:embedding:',
|
|
])('throws a clear error for %s with no model name', async (providerPath) => {
|
|
const factory = (await getProviderFactories(providerPath)).find((f) => f.test(providerPath));
|
|
expect(factory).toBeDefined();
|
|
await expect(factory!.create(providerPath, bareOptions, bareContext)).rejects.toThrow(
|
|
/Missing model name/,
|
|
);
|
|
});
|
|
});
|
|
});
|