Skip to content

Commit 0d7b3fe

Browse files
authored
feat(core): support promptfoo provider declarations
Accept Promptfoo-compatible provider strings and maps while preserving AgentV-only export diagnostics.
1 parent 27bd6ed commit 0d7b3fe

14 files changed

Lines changed: 1645 additions & 174 deletions

File tree

apps/web/src/content/docs/docs/next/targets/configuration.mdx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -295,20 +295,50 @@ tests:
295295
- id: test-2
296296
```
297297

298-
The string is a configured provider `label` or `id`. Use object form when an
299-
eval needs a local provider variant:
298+
The string is usually a configured provider `label` or `id`. Promptfoo-style
299+
provider spec strings such as `openai:gpt-4.1-mini` are also accepted and lower
300+
to an inline provider definition. Use object form when an eval needs a local
301+
provider variant:
300302

301303
```yaml
302304
providers:
303305
- id: codex-app-server
304306
label: codex-high-reasoning
305-
runtime: host
306-
config:
307-
command: ["codex", "app-server"]
308-
model: gpt-5-codex
309-
reasoning_effort: high
307+
runtime: host
308+
config:
309+
command: ["codex", "app-server"]
310+
model: gpt-5-codex
311+
reasoning_effort: high
310312
```
311313

314+
AgentV accepts the Promptfoo provider declaration layer where the semantics
315+
match: strings, object form, provider maps, and `inputs`. In object form, `id`
316+
is the backend/spec and `label` is the stable AgentV result and selection
317+
identity.
318+
319+
```yaml
320+
providers:
321+
- openai:gpt-4.1-mini
322+
- openai:gpt-4:
323+
label: gpt4-low-temp
324+
config:
325+
temperature: 0
326+
inputs:
327+
prompt: User prompt text
328+
- id: openai:responses:gpt-5.4
329+
label: gpt5-responses
330+
inputs:
331+
prompt:
332+
type: text
333+
description: User prompt text
334+
```
335+
336+
`runtime`, provider-local `environment`, and provider `hooks` are AgentV
337+
extensions. Direct Promptfoo runs do not execute those fields. A Promptfoo
338+
export path must either lower a supported AgentV extension into Promptfoo
339+
providers/extensions or reject the config with a clear diagnostic instead of
340+
silently dropping runtime or testbed behavior.
341+
312342
Use `defaults.grader` for the project default grader. `default_test.options.provider`
313343
and `tests[].options.provider` can choose a grader provider for LLM-backed
314344
assertions before an assertion-level `provider` override takes final precedence.
@@ -443,7 +473,6 @@ Provider hooks can be scoped to an eval-local provider object:
443473
providers:
444474
- id: codex-app-server
445475
label: codex-with-skills
446-
extends: default
447476
hooks:
448477
before_each:
449478
command: ["setup-plugins.sh", "skills"]

packages/core/src/evaluation/loaders/config-graph.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
22
import path from 'node:path';
33

44
import { isPlainConfigObject } from '../../config-overlays.js';
5-
import { normalizeProviderDefinition } from '../providers/targets.js';
5+
import { expandProviderDefinitionEntries } from '../providers/targets.js';
66
import { parseYamlValue } from '../yaml-loader.js';
77

88
const FILE_PROTOCOL = 'file://';
@@ -214,12 +214,17 @@ function parseArray(value: unknown, location: string): readonly unknown[] {
214214
}
215215

216216
function parseProviders(value: unknown, location: string): readonly NormalizedTargetConfig[] {
217-
return parseArray(value, location).map((entry, index) =>
218-
parseProvider(entry, `${location}[${index}]`),
219-
);
217+
return expandProviderDefinitionEntries(parseArray(value, location), {
218+
location,
219+
stringMode: 'all',
220+
}).map((entry) => parseProvider(entry.rawDefinition, entry.definition, `${location}`));
220221
}
221222

222-
function parseProvider(value: unknown, location: string): NormalizedTargetConfig {
223+
function parseProvider(
224+
value: unknown,
225+
definition: ReturnType<typeof expandProviderDefinitionEntries>[number]['definition'],
226+
location: string,
227+
): NormalizedTargetConfig {
223228
if (!isPlainConfigObject(value)) {
224229
throw new Error(`Invalid ${location}: provider must be an object.`);
225230
}
@@ -229,7 +234,6 @@ function parseProvider(value: unknown, location: string): NormalizedTargetConfig
229234
}
230235
}
231236

232-
const definition = normalizeProviderDefinition(value, { location });
233237
const id = definition.name;
234238
const provider = readRequiredString(definition.provider, `${location}.id`);
235239
if (AMBIGUOUS_PROVIDER_ALIASES.has(provider)) {

packages/core/src/evaluation/loaders/config-loader.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
import { getAgentvConfigDir } from '../../paths.js';
1313
import { createEvalConfigEnv, interpolateEnv } from '../interpolation.js';
1414
import {
15-
normalizeProviderDefinition,
15+
expandProviderDefinitionEntries,
16+
isProviderSpecString,
1617
resolveProviderDefinitionEnvironments,
1718
} from '../providers/targets.js';
1819
import type { ProviderDefinition } from '../providers/types.js';
@@ -326,14 +327,44 @@ function parseProviderDefinitions(
326327
if (!Array.isArray(rawProviders)) {
327328
return Promise.resolve(undefined);
328329
}
329-
const definitions = rawProviders.map((entry, index) =>
330-
normalizeProviderDefinition(entry, { location: `${configPath}:providers[${index}]` }),
331-
);
330+
const definitions = expandProviderDefinitionEntries(rawProviders, {
331+
location: `${configPath}:providers`,
332+
stringMode: 'all',
333+
}).map((entry) => entry.definition);
332334
return resolveProviderDefinitionEnvironments(definitions, baseDir, {
333335
location: `${configPath}:providers`,
334336
});
335337
}
336338

339+
function parseInlineProviderRefs(rawProviders: readonly unknown[]): readonly EvalTargetRef[] {
340+
const refs: EvalTargetRef[] = [];
341+
rawProviders.forEach((entry, index) => {
342+
const location = `providers[${index}]`;
343+
if (typeof entry === 'string' && !isProviderSpecString(entry)) {
344+
const name = entry.trim();
345+
if (name.length === 0) {
346+
throw new Error(`Invalid ${location}: provider reference must be non-empty.`);
347+
}
348+
refs.push({ name });
349+
return;
350+
}
351+
352+
refs.push(...parseEvalProviderRefs(entry, location));
353+
});
354+
return refs;
355+
}
356+
357+
function parseEvalProviderRefs(raw: unknown, location: string): readonly EvalTargetRef[] {
358+
const entries = expandProviderDefinitionEntries([raw], {
359+
location: location.replace(/\[\d+\]$/, ''),
360+
stringMode: 'spec-only',
361+
});
362+
363+
return entries.map((entry) =>
364+
providerDefinitionToRef(entry.rawDefinition, entry.rawId, entry.definition),
365+
);
366+
}
367+
337368
function mergeExecutionConfig(
338369
defaults: ExecutionDefaults | undefined,
339370
graph: ComposableConfigGraph['execution'],
@@ -543,7 +574,7 @@ export function extractTargetRefsFromSuite(
543574
}
544575

545576
const entries = Array.isArray(rawProviders) ? rawProviders : [rawProviders];
546-
const refs = entries.map((entry, index) => parseEvalProviderRef(entry, `providers[${index}]`));
577+
const refs = parseInlineProviderRefs(entries);
547578
assertUniqueProviderRefs(refs);
548579
return refs.length > 0 ? refs : undefined;
549580
}
@@ -558,29 +589,18 @@ export function extractTargetsFromSuite(suite: JsonObject): readonly string[] |
558589
return names.length > 0 ? names : undefined;
559590
}
560591

561-
function parseEvalProviderRef(raw: unknown, location: string): EvalTargetRef {
562-
if (typeof raw === 'string') {
563-
const name = raw.trim();
564-
if (name.length === 0) {
565-
throw new Error(`Invalid ${location}: provider reference must be non-empty.`);
566-
}
567-
return { name };
568-
}
569-
570-
if (!isJsonObject(raw)) {
571-
throw new Error(`Invalid ${location}: use a provider reference string or provider object.`);
572-
}
573-
592+
function providerDefinitionToRef(
593+
raw: Record<string, unknown>,
594+
rawId: string,
595+
definition: ProviderDefinition,
596+
): EvalTargetRef {
574597
const hooks = parseTargetHooks(raw.hooks);
575-
const definition = normalizeProviderDefinition(
576-
Object.fromEntries(Object.entries(raw).filter(([key]) => key !== 'hooks')),
577-
{ location },
578-
) as ProviderDefinition;
579-
598+
const rawLabel =
599+
typeof raw.label === 'string' && raw.label.trim().length > 0 ? raw.label.trim() : undefined;
580600
return {
581601
name: definition.name,
582-
id: typeof raw.id === 'string' ? raw.id.trim() : definition.provider,
583-
...(definition.label !== undefined ? { label: definition.label } : {}),
602+
id: rawId,
603+
...(rawLabel !== undefined ? { label: rawLabel } : {}),
584604
definition,
585605
...(hooks !== undefined ? { hooks } : {}),
586606
};

packages/core/src/evaluation/providers/targets-file.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { access, readFile } from 'node:fs/promises';
33
import path from 'node:path';
44

55
import { parseYamlValue } from '../yaml-loader.js';
6-
import { normalizeProviderDefinition, resolveProviderDefinitionEnvironments } from './targets.js';
6+
import {
7+
expandProviderDefinitionEntries,
8+
resolveProviderDefinitionEnvironments,
9+
} from './targets.js';
710
import { TARGETS_SCHEMA_V2 } from './types.js';
811
import type { ProviderDefinition } from './types.js';
912

@@ -32,24 +35,6 @@ function extractProvidersArray(parsed: unknown, absolutePath: string): unknown[]
3235
return providers;
3336
}
3437

35-
function assertProviderDefinition(
36-
value: unknown,
37-
index: number,
38-
filePath: string,
39-
): ProviderDefinition {
40-
if (!isRecord(value)) {
41-
throw new Error(`providers entry at index ${index} in ${filePath} must be an object`);
42-
}
43-
44-
const id = value.id;
45-
46-
if (typeof id !== 'string' || id.trim().length === 0) {
47-
throw new Error(`providers entry at index ${index} in ${filePath} is missing a valid 'id'`);
48-
}
49-
50-
return normalizeProviderDefinition(value, { location: `providers[${index}]` });
51-
}
52-
5338
async function fileExists(filePath: string): Promise<boolean> {
5439
try {
5540
await access(filePath, constants.F_OK);
@@ -71,9 +56,10 @@ export async function readProviderDefinitions(
7156
const parsed = parseYamlValue(raw);
7257

7358
const providers = extractProvidersArray(parsed, absolutePath);
74-
const definitions = providers.map((entry, index) =>
75-
assertProviderDefinition(entry, index, absolutePath),
76-
);
59+
const definitions = expandProviderDefinitionEntries(providers, {
60+
location: 'providers',
61+
stringMode: 'all',
62+
}).map((entry) => entry.definition);
7763
return resolveProviderDefinitionEnvironments(definitions, path.dirname(absolutePath), {
7864
location: 'providers',
7965
});

packages/core/src/evaluation/providers/targets.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,99 @@ export interface NormalizeProviderDefinitionOptions {
699699
readonly location?: string;
700700
}
701701

702+
export type ExpandedProviderDefinitionEntry = {
703+
readonly rawId: string;
704+
readonly rawDefinition: Record<string, unknown>;
705+
readonly definition: ProviderDefinition;
706+
};
707+
708+
export function isProviderSpecString(value: string): boolean {
709+
const trimmed = value.trim();
710+
return trimmed.startsWith('package:') || trimmed.startsWith('file://') || trimmed.includes(':');
711+
}
712+
713+
export function expandProviderDefinitionEntries(
714+
entries: readonly unknown[],
715+
options: {
716+
readonly location?: string;
717+
readonly stringMode?: 'all' | 'spec-only';
718+
} = {},
719+
): readonly ExpandedProviderDefinitionEntry[] {
720+
const location = options.location ?? 'providers';
721+
const stringMode = options.stringMode ?? 'all';
722+
const expanded: ExpandedProviderDefinitionEntry[] = [];
723+
724+
entries.forEach((entry, index) => {
725+
const entryLocation = `${location}[${index}]`;
726+
if (typeof entry === 'string') {
727+
const id = entry.trim();
728+
if (id.length === 0) {
729+
throw new Error(`Invalid ${entryLocation}: provider string must be non-empty.`);
730+
}
731+
if (stringMode === 'spec-only' && !isProviderSpecString(id)) {
732+
return;
733+
}
734+
const rawDefinition = { id };
735+
expanded.push({
736+
rawId: id,
737+
rawDefinition,
738+
definition: normalizeProviderDefinition(rawDefinition, { location: entryLocation }),
739+
});
740+
return;
741+
}
742+
743+
if (!isRecord(entry)) {
744+
throw new Error(`Invalid ${entryLocation}: provider must be a string or object.`);
745+
}
746+
747+
if (typeof entry.id === 'string' && entry.id.trim().length > 0) {
748+
expanded.push({
749+
rawId: entry.id.trim(),
750+
rawDefinition: entry,
751+
definition: normalizeProviderDefinition(entry, { location: entryLocation }),
752+
});
753+
return;
754+
}
755+
756+
if (
757+
entry.provider !== undefined ||
758+
entry.name !== undefined ||
759+
entry.container !== undefined ||
760+
entry.install !== undefined
761+
) {
762+
normalizeProviderDefinition(entry, { location: entryLocation });
763+
return;
764+
}
765+
766+
const mapEntries = Object.entries(entry);
767+
if (mapEntries.length === 0) {
768+
throw new Error(`Invalid ${entryLocation}: provider map must not be empty.`);
769+
}
770+
771+
for (const [providerId, providerOptions] of mapEntries) {
772+
if (providerId.trim().length === 0) {
773+
throw new Error(`Invalid ${entryLocation}: provider map key must be non-empty.`);
774+
}
775+
if (!isRecord(providerOptions)) {
776+
throw new Error(
777+
`Invalid ${entryLocation}.${providerId}: provider map value must be an object.`,
778+
);
779+
}
780+
const { id: _ignoredId, ...optionsWithoutId } = providerOptions;
781+
const rawDefinition = { ...optionsWithoutId, id: providerId };
782+
expanded.push({
783+
rawId: providerId,
784+
rawDefinition,
785+
definition: normalizeProviderDefinition(rawDefinition, {
786+
location: `${entryLocation}.${providerId}`,
787+
}),
788+
});
789+
}
790+
});
791+
792+
return expanded;
793+
}
794+
702795
function normalizePublicProviderId(providerId: string): {
703796
readonly provider: string;
704797
readonly config: Record<string, unknown>;

packages/core/src/evaluation/providers/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,7 @@ export interface ProviderDefinition {
466466
readonly prompts?: unknown | undefined;
467467
readonly transform?: unknown | undefined;
468468
readonly delay?: number | unknown | undefined;
469+
readonly inputs?: unknown | undefined;
469470
readonly provider?: ProviderKind | string;
470471
/** Original public providers[].id backend/spec string after public provider normalization. */
471472
readonly provider_spec?: string | undefined;

0 commit comments

Comments
 (0)