Files
promptfoo--promptfoo/test/util/provider.test.ts
T
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

621 lines
23 KiB
TypeScript

import * as path from 'path';
import { describe, expect, it, vi } from 'vitest';
import {
checkProviderApiKeys,
doesProviderRefMatch,
getProviderDescription,
getProviderIdentifier,
isAnthropicProvider,
isGoogleProvider,
isOpenAiProvider,
isProviderAllowed,
providerToIdentifier,
sanitizeProviderIdForLog,
} from '../../src/util/provider';
import {
canonicalizeProviderId,
isProviderConfigFileReference,
normalizeProviderRef,
} from '../../src/util/providerRef';
import { createMockProvider } from '../factories/provider';
import type { ApiProvider } from '../../src/types/index';
describe('normalizeProviderRef', () => {
it('identifies provider config file references without treating script providers as config', () => {
expect(isProviderConfigFileReference('file://providers.yaml')).toBe(true);
expect(isProviderConfigFileReference('file://providers.yml')).toBe(true);
expect(isProviderConfigFileReference('file://providers.json')).toBe(true);
expect(isProviderConfigFileReference('file://provider.js')).toBe(false);
});
it('normalizes ProviderOptionsMap refs with explicit id overrides', () => {
const descriptor = normalizeProviderRef({
'openai:responses:gpt-5.4': {
id: 'custom-openai',
label: 'Fast OpenAI',
config: { temperature: 0.2 },
},
});
expect(descriptor).toMatchObject({
kind: 'map',
id: 'custom-openai',
label: 'Fast OpenAI',
loadProviderPath: 'openai:responses:gpt-5.4',
loadOptions: {
id: 'custom-openai',
label: 'Fast OpenAI',
config: { temperature: 0.2 },
},
});
});
it('does not treat ProviderOptions-shaped objects as ProviderOptionsMap refs', () => {
expect(normalizeProviderRef({ config: { temperature: 0.2 } })).toMatchObject({
kind: 'unknown',
id: 'unknown',
});
expect(normalizeProviderRef({ prompts: ['prompt1'] })).toMatchObject({
kind: 'unknown',
id: 'unknown',
});
expect(normalizeProviderRef({ config: { id: 'openai:gpt-4' } })).toMatchObject({
kind: 'unknown',
id: 'unknown',
});
});
it('requires ProviderOptionsMap refs to use a single provider id key', () => {
expect(
normalizeProviderRef({
'openai:gpt-4': { config: { temperature: 0.2 } },
'anthropic:messages:claude-sonnet-4-5': { config: { temperature: 0.1 } },
}),
).toMatchObject({
kind: 'unknown',
id: 'unknown',
});
});
it('guards every ProviderOptions key against ProviderOptionsMap misclassification', () => {
// Each key from the ProviderOptions interface must be guarded so that
// objects like { transform: "..." } are not mistaken for { "transform": { ...providerOpts } }.
// If you add a key to ProviderOptions, add it here too.
const providerOptionKeys = [
'id',
'label',
'config',
'prompts',
'transform',
'delay',
'env',
'inputs',
];
for (const key of providerOptionKeys) {
const obj = { [key]: { nested: true } };
const descriptor = normalizeProviderRef(obj);
expect(descriptor.kind, `key "${key}" should not produce kind 'map'`).not.toBe('map');
}
});
it('uses function labels before positional custom-function fallbacks', () => {
const labeled = Object.assign(async () => ({ output: 'ok' }), { label: 'custom-label' });
const unlabeled = async () => ({ output: 'ok' });
expect(normalizeProviderRef(labeled, { index: 3 })).toMatchObject({
kind: 'function',
id: 'custom-label',
label: 'custom-label',
});
expect(normalizeProviderRef(unlabeled, { index: 3 })).toMatchObject({
kind: 'function',
id: 'custom-function-3',
});
});
it('falls back to labels for malformed provider configs used by filtering', () => {
expect(normalizeProviderRef({ id: '', label: 'Provider2' }, { index: 1 })).toMatchObject({
kind: 'unknown',
id: 'Provider2',
label: 'Provider2',
});
});
it('classifies string providers as named vs file based on extension', () => {
expect(normalizeProviderRef('openai:responses:gpt-5.4')).toMatchObject({
kind: 'named',
id: 'openai:responses:gpt-5.4',
loadProviderPath: 'openai:responses:gpt-5.4',
});
expect(normalizeProviderRef('file://providers.yaml')).toMatchObject({
kind: 'file',
id: 'file://providers.yaml',
loadProviderPath: 'file://providers.yaml',
});
expect(normalizeProviderRef('file://provider.js')).toMatchObject({
kind: 'named',
id: 'file://provider.js',
loadProviderPath: 'file://provider.js',
});
});
it('normalizes ProviderOptionsMap without nested id override (key becomes id)', () => {
const descriptor = normalizeProviderRef({
'openai:responses:gpt-5.4': {
config: { temperature: 0.2 },
},
});
expect(descriptor).toMatchObject({
kind: 'map',
id: 'openai:responses:gpt-5.4',
loadProviderPath: 'openai:responses:gpt-5.4',
loadOptions: {
id: 'openai:responses:gpt-5.4',
config: { temperature: 0.2 },
},
});
});
it('returns kind unknown for empty string provider IDs', () => {
expect(normalizeProviderRef('')).toMatchObject({ kind: 'unknown', id: 'unknown' });
expect(normalizeProviderRef('', { index: 2 })).toMatchObject({
kind: 'unknown',
id: 'unknown-2',
});
});
it('returns kind unknown for null, undefined, and non-provider types', () => {
expect(normalizeProviderRef(null)).toMatchObject({ kind: 'unknown', id: 'unknown' });
expect(normalizeProviderRef(undefined)).toMatchObject({ kind: 'unknown', id: 'unknown' });
expect(normalizeProviderRef(42)).toMatchObject({ kind: 'unknown', id: 'unknown' });
expect(normalizeProviderRef([])).toMatchObject({ kind: 'unknown', id: 'unknown' });
expect(normalizeProviderRef([{ id: 'openai:gpt-4' }])).toMatchObject({
kind: 'unknown',
id: 'unknown',
});
});
});
describe('canonicalizeProviderId', () => {
it('resolves relative file:// paths to absolute', () => {
const cwd = process.cwd();
expect(canonicalizeProviderId('file://./provider.js')).toBe(
`file://${path.join(cwd, 'provider.js')}`,
);
});
it('preserves absolute file:// paths', () => {
expect(canonicalizeProviderId('file:///absolute/path.js')).toBe('file:///absolute/path.js');
});
it('resolves exec: paths with slashes', () => {
const cwd = process.cwd();
expect(canonicalizeProviderId('exec:./script.py')).toBe(`exec:${path.join(cwd, 'script.py')}`);
});
it('preserves exec: paths without slashes', () => {
expect(canonicalizeProviderId('exec:my-script')).toBe('exec:my-script');
});
it('resolves python: paths with slashes', () => {
const cwd = process.cwd();
expect(canonicalizeProviderId('python:./provider.py')).toBe(
`python:${path.join(cwd, 'provider.py')}`,
);
});
it('resolves golang: paths with slashes', () => {
const cwd = process.cwd();
expect(canonicalizeProviderId('golang:./main.go')).toBe(`golang:${path.join(cwd, 'main.go')}`);
});
it('preserves golang: paths without slashes', () => {
expect(canonicalizeProviderId('golang:my-binary')).toBe('golang:my-binary');
});
it('wraps bare .js/.ts/.mjs paths with file://', () => {
const cwd = process.cwd();
expect(canonicalizeProviderId('./provider.js')).toBe(`file://${path.join(cwd, 'provider.js')}`);
expect(canonicalizeProviderId('./provider.ts')).toBe(`file://${path.join(cwd, 'provider.ts')}`);
expect(canonicalizeProviderId('./provider.mjs')).toBe(
`file://${path.join(cwd, 'provider.mjs')}`,
);
});
it('does not wrap bare .js/.ts/.mjs without path separators', () => {
expect(canonicalizeProviderId('provider.js')).toBe('provider.js');
});
it('passes through plain provider IDs unchanged', () => {
expect(canonicalizeProviderId('openai:responses:gpt-5.4')).toBe('openai:responses:gpt-5.4');
expect(canonicalizeProviderId('echo')).toBe('echo');
});
});
describe('providerToIdentifier', () => {
it('works with provider string', () => {
expect(providerToIdentifier('gpt-3.5-turbo')).toStrictEqual('gpt-3.5-turbo');
});
it('works with provider id undefined', () => {
expect(providerToIdentifier(undefined)).toBeUndefined();
});
it('works with ApiProvider', () => {
const providerId = 'custom';
const apiProvider = {
id() {
return providerId;
},
callApi: vi.fn(),
} as ApiProvider;
expect(providerToIdentifier(apiProvider)).toStrictEqual(providerId);
});
it('works with ProviderOptions', () => {
const providerId = 'custom';
const providerOptions = {
id: providerId,
};
expect(providerToIdentifier(providerOptions)).toStrictEqual(providerId);
});
it('uses label when present on ProviderOptions', () => {
const providerOptions = {
id: 'file://provider.js',
label: 'my-provider',
};
expect(providerToIdentifier(providerOptions)).toStrictEqual('my-provider');
});
it('canonicalizes relative file paths to absolute', () => {
const originalCwd = process.cwd();
expect(providerToIdentifier('file://./provider.js')).toStrictEqual(
`file://${path.join(originalCwd, 'provider.js')}`,
);
});
it('canonicalizes JavaScript files without file:// prefix', () => {
const originalCwd = process.cwd();
expect(providerToIdentifier('./provider.js')).toStrictEqual(
`file://${path.join(originalCwd, 'provider.js')}`,
);
});
it('preserves absolute file paths', () => {
expect(providerToIdentifier('file:///absolute/path/provider.js')).toStrictEqual(
'file:///absolute/path/provider.js',
);
});
it('canonicalizes exec: paths', () => {
const originalCwd = process.cwd();
expect(providerToIdentifier('exec:./script.py')).toStrictEqual(
`exec:${path.join(originalCwd, 'script.py')}`,
);
});
it('canonicalizes python: paths', () => {
const originalCwd = process.cwd();
expect(providerToIdentifier('python:./provider.py')).toStrictEqual(
`python:${path.join(originalCwd, 'provider.py')}`,
);
});
});
describe('getProviderIdentifier', () => {
it('returns label when present', () => {
const provider = createMockProvider({ id: 'openai:gpt-4', label: 'my-custom-label' });
expect(getProviderIdentifier(provider)).toBe('my-custom-label');
});
it('returns id when no label', () => {
const provider = createMockProvider({ id: 'openai:gpt-4' });
expect(getProviderIdentifier(provider)).toBe('openai:gpt-4');
});
});
describe('getProviderDescription', () => {
it('returns both label and id when both present', () => {
const provider = createMockProvider({ id: 'openai:gpt-4', label: 'my-custom-label' });
expect(getProviderDescription(provider)).toBe('my-custom-label (openai:gpt-4)');
});
it('returns only id when no label', () => {
const provider = createMockProvider({ id: 'openai:gpt-4' });
expect(getProviderDescription(provider)).toBe('openai:gpt-4');
});
it('returns only id when label equals id', () => {
const provider = createMockProvider({ id: 'openai:gpt-4', label: 'openai:gpt-4' });
expect(getProviderDescription(provider)).toBe('openai:gpt-4');
});
});
describe('sanitizeProviderIdForLog', () => {
it('redacts credentials in URL provider IDs', () => {
expect(
sanitizeProviderIdForLog(
'http://example.com/api?cursor=sk-123456789012345678901234567890&safe=ok',
),
).toBe('http://example.com/api?cursor=%5BREDACTED%5D&safe=ok');
});
it('leaves non-URL provider IDs unchanged', () => {
expect(sanitizeProviderIdForLog('openai:gpt-4.1')).toBe('openai:gpt-4.1');
});
it('preserves provider descriptions while sanitizing URL IDs', () => {
expect(
sanitizeProviderIdForLog(
'http://example.com/api?cursor=sk-123456789012345678901234567890 (HttpProvider)',
),
).toBe('http://example.com/api?cursor=%5BREDACTED%5D (HttpProvider)');
});
});
describe('doesProviderRefMatch', () => {
const createProvider = (id: string, label?: string): ApiProvider =>
createMockProvider({ id, label });
it('matches exact label', () => {
const provider = createProvider('openai:gpt-4', 'fast-model');
expect(doesProviderRefMatch('fast-model', provider)).toBe(true);
expect(doesProviderRefMatch('slow-model', provider)).toBe(false);
});
it('matches exact id', () => {
const provider = createProvider('openai:gpt-4');
expect(doesProviderRefMatch('openai:gpt-4', provider)).toBe(true);
expect(doesProviderRefMatch('openai:gpt-3.5', provider)).toBe(false);
});
it('matches wildcard on id', () => {
const provider = createProvider('openai:gpt-4');
expect(doesProviderRefMatch('openai:*', provider)).toBe(true);
expect(doesProviderRefMatch('anthropic:*', provider)).toBe(false);
});
it('matches wildcard on label', () => {
const provider = createProvider('openai:gpt-4', 'openai-fast');
expect(doesProviderRefMatch('openai-*', provider)).toBe(true);
expect(doesProviderRefMatch('anthropic-*', provider)).toBe(false);
});
it('matches legacy prefix on id', () => {
const provider = createProvider('openai:gpt-4');
expect(doesProviderRefMatch('openai', provider)).toBe(true);
expect(doesProviderRefMatch('anthropic', provider)).toBe(false);
});
it('matches legacy prefix on label', () => {
const provider = createProvider('custom-provider', 'openai:custom');
expect(doesProviderRefMatch('openai', provider)).toBe(true);
});
it('does not match partial strings', () => {
const provider = createProvider('openai:gpt-4');
expect(doesProviderRefMatch('openai:gpt', provider)).toBe(false);
expect(doesProviderRefMatch('gpt-4', provider)).toBe(false);
});
it('matches canonicalized file paths', () => {
// Provider has relative path, ref uses absolute - should still match after canonicalization
const absolutePath = path.resolve('./my-provider.js');
const provider = createProvider(`file://${absolutePath}`);
expect(doesProviderRefMatch('./my-provider.js', provider)).toBe(true);
expect(doesProviderRefMatch(`file://${absolutePath}`, provider)).toBe(true);
});
});
describe('isProviderAllowed', () => {
const createProvider = (id: string, label?: string): ApiProvider =>
createMockProvider({ id, label });
it('allows all providers when no filter', () => {
const provider = createProvider('openai:gpt-4');
expect(isProviderAllowed(provider, undefined)).toBe(true);
});
it('blocks all providers with empty array', () => {
const provider = createProvider('openai:gpt-4');
expect(isProviderAllowed(provider, [])).toBe(false);
});
it('allows matching providers', () => {
const provider = createProvider('openai:gpt-4', 'fast-model');
expect(isProviderAllowed(provider, ['fast-model'])).toBe(true);
expect(isProviderAllowed(provider, ['openai:gpt-4'])).toBe(true);
expect(isProviderAllowed(provider, ['openai:*'])).toBe(true);
});
it('blocks non-matching providers', () => {
const provider = createProvider('openai:gpt-4', 'fast-model');
expect(isProviderAllowed(provider, ['slow-model'])).toBe(false);
expect(isProviderAllowed(provider, ['anthropic:*'])).toBe(false);
});
it('allows if any filter matches', () => {
const provider = createProvider('openai:gpt-4');
expect(isProviderAllowed(provider, ['anthropic:*', 'openai:*'])).toBe(true);
expect(isProviderAllowed(provider, ['anthropic:*', 'google:*'])).toBe(false);
});
});
describe('isOpenAiProvider', () => {
it('detects direct OpenAI providers', () => {
expect(isOpenAiProvider('openai:chat:gpt-4o')).toBe(true);
expect(isOpenAiProvider('openai:gpt-4')).toBe(true);
expect(isOpenAiProvider('openai:completion:gpt-3.5-turbo')).toBe(true);
expect(isOpenAiProvider('openai:embedding:text-embedding-ada-002')).toBe(true);
});
it('detects Azure OpenAI providers', () => {
// azureopenai: is always OpenAI
expect(isOpenAiProvider('azureopenai:chat:my-deployment')).toBe(true);
// azure: with OpenAI model indicators
expect(isOpenAiProvider('azure:chat:gpt-4-deployment')).toBe(true);
expect(isOpenAiProvider('azure:chat:my-gpt-35-turbo')).toBe(true);
expect(isOpenAiProvider('azure:completion:davinci-002')).toBe(true);
expect(isOpenAiProvider('azure:embedding:text-embedding-ada-002')).toBe(true);
});
it('does not match Azure without OpenAI model indicators', () => {
expect(isOpenAiProvider('azure:chat:my-custom-deployment')).toBe(false);
expect(isOpenAiProvider('azure:foundry-agent:my-agent')).toBe(false);
});
it('is case insensitive', () => {
expect(isOpenAiProvider('OpenAI:chat:gpt-4')).toBe(true);
expect(isOpenAiProvider('AZUREOPENAI:chat:my-deployment')).toBe(true);
expect(isOpenAiProvider('AZURE:chat:GPT-4-deployment')).toBe(true);
});
it('does not match non-OpenAI providers', () => {
expect(isOpenAiProvider('anthropic:messages:claude-3-5-sonnet')).toBe(false);
expect(isOpenAiProvider('google:gemini-pro')).toBe(false);
expect(isOpenAiProvider('bedrock:anthropic.claude-3-5-sonnet')).toBe(false);
expect(isOpenAiProvider('vertex:gemini-2.0-flash')).toBe(false);
});
});
describe('isAnthropicProvider', () => {
it('detects direct Anthropic providers', () => {
expect(isAnthropicProvider('anthropic:messages:claude-3-5-sonnet')).toBe(true);
expect(isAnthropicProvider('anthropic:completion:claude-2.1')).toBe(true);
expect(isAnthropicProvider('anthropic:claude-opus-4-6')).toBe(true);
expect(isAnthropicProvider('anthropic:claude-opus-4-5-20251101')).toBe(true);
});
it('detects Bedrock with Claude models', () => {
expect(isAnthropicProvider('bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0')).toBe(true);
expect(isAnthropicProvider('bedrock:anthropic.claude-opus-4-6-v1')).toBe(true);
expect(isAnthropicProvider('bedrock:anthropic.claude-opus-4-5-20251101-v1:0')).toBe(true);
expect(isAnthropicProvider('bedrock:converse:anthropic.claude-3-opus-20240229-v1:0')).toBe(
true,
);
expect(isAnthropicProvider('bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0')).toBe(true);
expect(isAnthropicProvider('bedrock:eu.anthropic.claude-3-haiku-20240307-v1:0')).toBe(true);
});
it('detects Vertex with Claude models', () => {
expect(isAnthropicProvider('vertex:claude-3-5-sonnet@20240620')).toBe(true);
expect(isAnthropicProvider('vertex:claude-3-opus@20240229')).toBe(true);
});
it('is case insensitive', () => {
expect(isAnthropicProvider('ANTHROPIC:messages:claude-3-5-sonnet')).toBe(true);
expect(isAnthropicProvider('BEDROCK:anthropic.CLAUDE-3-5-sonnet')).toBe(true);
expect(isAnthropicProvider('VERTEX:CLAUDE-3-opus')).toBe(true);
});
it('does not match non-Anthropic providers', () => {
expect(isAnthropicProvider('openai:chat:gpt-4o')).toBe(false);
expect(isAnthropicProvider('google:gemini-pro')).toBe(false);
expect(isAnthropicProvider('bedrock:amazon.titan-text-express-v1')).toBe(false);
expect(isAnthropicProvider('vertex:gemini-2.0-flash')).toBe(false);
});
});
describe('isGoogleProvider', () => {
it('detects direct Google AI Studio providers', () => {
expect(isGoogleProvider('google:gemini-pro')).toBe(true);
expect(isGoogleProvider('google:gemini-2.0-flash')).toBe(true);
expect(isGoogleProvider('google:live:gemini-2.0-flash')).toBe(true);
expect(isGoogleProvider('google:image:gemini-pro-vision')).toBe(true);
});
it('detects Vertex with Google models', () => {
expect(isGoogleProvider('vertex:gemini-2.0-flash')).toBe(true);
expect(isGoogleProvider('vertex:gemini-pro')).toBe(true);
expect(isGoogleProvider('vertex:text-bison')).toBe(true);
expect(isGoogleProvider('vertex:llama-3.3-70b')).toBe(true);
});
it('is case insensitive', () => {
expect(isGoogleProvider('GOOGLE:gemini-pro')).toBe(true);
expect(isGoogleProvider('VERTEX:GEMINI-2.0-flash')).toBe(true);
});
it('does not match Vertex with Claude models (they are Anthropic)', () => {
expect(isGoogleProvider('vertex:claude-3-5-sonnet@20240620')).toBe(false);
expect(isGoogleProvider('vertex:claude-3-opus@20240229')).toBe(false);
});
it('does not match non-Google providers', () => {
expect(isGoogleProvider('openai:chat:gpt-4o')).toBe(false);
expect(isGoogleProvider('anthropic:messages:claude-3-5-sonnet')).toBe(false);
expect(isGoogleProvider('bedrock:anthropic.claude-3-5-sonnet')).toBe(false);
});
});
describe('checkProviderApiKeys', () => {
const providerWithKey = (id: string, extras: Record<string, unknown> = {}): ApiProvider =>
Object.assign(createMockProvider({ id, config: {} }), {
getApiKey: () => undefined,
requiresApiKey: () => true,
...extras,
}) as unknown as ApiProvider;
it('detects missing API key and maps to correct env var', () => {
const provider = providerWithKey('openai:gpt-4');
const result = checkProviderApiKeys([provider]);
expect(result.size).toBe(1);
expect(result.get('OPENAI_API_KEY')).toEqual(['openai:gpt-4']);
});
it('uses descriptions only when requested for user-facing error output', () => {
const provider = providerWithKey('openai:gpt-4', { label: 'primary' });
expect(checkProviderApiKeys([provider]).get('OPENAI_API_KEY')).toEqual(['openai:gpt-4']);
expect(
checkProviderApiKeys([provider], { useDescriptions: true }).get('OPENAI_API_KEY'),
).toEqual(['primary (openai:gpt-4)']);
});
it('skips providers with valid key, no getApiKey method, or requiresApiKey false', () => {
const providers: ApiProvider[] = [
providerWithKey('openai:gpt-4', { getApiKey: () => 'sk-1234' }),
createMockProvider({ id: 'http:custom' }),
providerWithKey('openai:gpt-4', { requiresApiKey: () => false }),
Object.assign(
createMockProvider({ id: 'litellm:gpt-4', config: { apiKeyRequired: false } }),
{ getApiKey: () => undefined },
) as unknown as ApiProvider,
];
const result = checkProviderApiKeys(providers);
expect(result.size).toBe(0);
});
it('skips azure providers (Azure AD token auth)', () => {
const provider = providerWithKey('azure:gpt-4');
const result = checkProviderApiKeys([provider]);
expect(result.size).toBe(0);
});
it('deduplicates multiple providers sharing the same env var', () => {
const providers: ApiProvider[] = [
providerWithKey('openai:gpt-4'),
providerWithKey('openai:gpt-5-mini'),
Object.assign(
createMockProvider({ id: 'anthropic:claude-sonnet-4-5-20250514', config: {} }),
{ getApiKey: () => undefined },
) as unknown as ApiProvider,
];
const result = checkProviderApiKeys(providers);
expect(result.size).toBe(2);
expect(result.get('OPENAI_API_KEY')).toEqual(['openai:gpt-4', 'openai:gpt-5-mini']);
expect(result.get('ANTHROPIC_API_KEY')).toEqual(['anthropic:claude-sonnet-4-5-20250514']);
});
});