Skip to content

Commit fd244fb

Browse files
committed
feat(core): support promptfoo provider declaration forms
1 parent e6f126c commit fd244fb

14 files changed

Lines changed: 1633 additions & 169 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: 42 additions & 24 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'],
@@ -541,7 +572,7 @@ export function extractTargetRefsFromSuite(
541572
}
542573

543574
const entries = Array.isArray(rawProviders) ? rawProviders : [rawProviders];
544-
const refs = entries.map((entry, index) => parseEvalProviderRef(entry, `providers[${index}]`));
575+
const refs = parseInlineProviderRefs(entries);
545576
assertUniqueProviderRefs(refs);
546577
return refs.length > 0 ? refs : undefined;
547578
}
@@ -556,28 +587,15 @@ export function extractTargetsFromSuite(suite: JsonObject): readonly string[] |
556587
return names.length > 0 ? names : undefined;
557588
}
558589

559-
function parseEvalProviderRef(raw: unknown, location: string): EvalTargetRef {
560-
if (typeof raw === 'string') {
561-
const name = raw.trim();
562-
if (name.length === 0) {
563-
throw new Error(`Invalid ${location}: provider reference must be non-empty.`);
564-
}
565-
return { name };
566-
}
567-
568-
if (!isJsonObject(raw)) {
569-
throw new Error(`Invalid ${location}: use a provider reference string or provider object.`);
570-
}
571-
590+
function providerDefinitionToRef(
591+
raw: Record<string, unknown>,
592+
rawId: string,
593+
definition: ProviderDefinition,
594+
): EvalTargetRef {
572595
const hooks = parseTargetHooks(raw.hooks);
573-
const definition = normalizeProviderDefinition(
574-
Object.fromEntries(Object.entries(raw).filter(([key]) => key !== 'hooks')),
575-
{ location },
576-
) as ProviderDefinition;
577-
578596
return {
579597
name: definition.name,
580-
id: typeof raw.id === 'string' ? raw.id.trim() : definition.provider,
598+
id: rawId,
581599
...(definition.label !== undefined ? { label: definition.label } : {}),
582600
definition,
583601
...(hooks !== undefined ? { hooks } : {}),

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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,89 @@ 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+
const mapEntries = Object.entries(entry);
757+
if (mapEntries.length === 0) {
758+
throw new Error(`Invalid ${entryLocation}: provider map must not be empty.`);
759+
}
760+
761+
for (const [providerId, providerOptions] of mapEntries) {
762+
if (providerId.trim().length === 0) {
763+
throw new Error(`Invalid ${entryLocation}: provider map key must be non-empty.`);
764+
}
765+
if (!isRecord(providerOptions)) {
766+
throw new Error(
767+
`Invalid ${entryLocation}.${providerId}: provider map value must be an object.`,
768+
);
769+
}
770+
const { id: _ignoredId, ...optionsWithoutId } = providerOptions;
771+
const rawDefinition = { ...optionsWithoutId, id: providerId };
772+
expanded.push({
773+
rawId: providerId,
774+
rawDefinition,
775+
definition: normalizeProviderDefinition(rawDefinition, {
776+
location: `${entryLocation}.${providerId}`,
777+
}),
778+
});
779+
}
780+
});
781+
782+
return expanded;
783+
}
784+
702785
function normalizePublicProviderId(providerId: string): {
703786
readonly provider: string;
704787
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)