Skip to content

Commit 2a53c40

Browse files
committed
feat: accept array of models in experiment
1 parent df205bb commit 2a53c40

5 files changed

Lines changed: 102 additions & 30 deletions

File tree

src/cli.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,19 @@ async function runExperimentCommand(configInput: string, options: { dry?: boolea
101101
console.log(chalk.green(` - ${name}`));
102102
}
103103

104-
console.log(chalk.blue(`\nRunning ${evalNames.length} eval(s) x ${config.runs} run(s) = ${evalNames.length * config.runs} total runs`));
105-
console.log(chalk.blue(`Agent: ${config.agent}, Model: ${config.model}, Timeout: ${config.timeout}s, Early Exit: ${config.earlyExit}`));
104+
const models = Array.isArray(config.model) ? config.model : [config.model];
105+
106+
// Show info for all models
107+
const totalRunsPerModel = evalNames.length * config.runs;
108+
const totalRuns = totalRunsPerModel * models.length;
109+
110+
if (models.length > 1) {
111+
console.log(chalk.blue(`\nRunning ${evalNames.length} eval(s) x ${config.runs} run(s) x ${models.length} model(s) = ${totalRuns} total runs`));
112+
console.log(chalk.blue(`Agent: ${config.agent}, Models: ${models.join(', ')}, Timeout: ${config.timeout}s, Early Exit: ${config.earlyExit}`));
113+
} else {
114+
console.log(chalk.blue(`\nRunning ${evalNames.length} eval(s) x ${config.runs} run(s) = ${totalRuns} total runs`));
115+
console.log(chalk.blue(`Agent: ${config.agent}, Model: ${models[0]}, Timeout: ${config.timeout}s, Early Exit: ${config.earlyExit}`));
116+
}
106117

107118
// Show which sandbox backend will be used
108119
const sandboxInfo = getSandboxBackendInfo({ backend: config.sandbox });
@@ -127,23 +138,44 @@ async function runExperimentCommand(configInput: string, options: { dry?: boolea
127138
const selectedFixtures = fixtures.filter((f) => evalNames.includes(f.name));
128139

129140
// Get experiment name from config file
130-
const experimentName = basename(configPath, '.ts').replace(/\.js$/, '');
141+
const baseExperimentName = basename(configPath, '.ts').replace(/\.js$/, '');
131142
const resultsDir = resolve(process.cwd(), 'results');
132143

133144
console.log(chalk.blue('\nStarting experiment...'));
134145

135-
// Run the experiment
136-
const results = await runExperiment({
137-
config,
138-
fixtures: selectedFixtures,
139-
apiKey,
140-
resultsDir,
141-
experimentName,
142-
onProgress: (msg) => console.log(msg),
143-
});
146+
// Run experiments for each model
147+
let allPassed = true;
148+
for (const model of models) {
149+
// Create a config for this specific model
150+
const modelConfig = { ...config, model };
151+
152+
// Include model in experiment name when multiple models are specified
153+
const experimentName = models.length > 1
154+
? `${baseExperimentName}/${model}`
155+
: baseExperimentName;
156+
157+
if (models.length > 1) {
158+
console.log(chalk.blue(`\n--- Running with model: ${model} ---`));
159+
}
160+
161+
// Run the experiment
162+
const results = await runExperiment({
163+
config: modelConfig,
164+
fixtures: selectedFixtures,
165+
apiKey,
166+
resultsDir,
167+
experimentName,
168+
onProgress: (msg) => console.log(msg),
169+
});
170+
171+
// Check if this experiment passed
172+
const experimentPassed = results.evals.every((e) => e.passedRuns === e.totalRuns);
173+
if (!experimentPassed) {
174+
allPassed = false;
175+
}
176+
}
144177

145178
// Exit with appropriate code
146-
const allPassed = results.evals.every((e) => e.passedRuns === e.totalRuns);
147179
process.exit(allPassed ? 0 : 1);
148180
} catch (error) {
149181
if (error instanceof Error) {

src/lib/config.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ describe('validateConfig', () => {
2525
expect(() => validateConfig(config)).not.toThrow();
2626
});
2727

28+
it('accepts array of models', () => {
29+
const config = {
30+
agent: 'claude-code',
31+
model: ['opus', 'sonnet', 'haiku'],
32+
};
33+
expect(() => validateConfig(config)).not.toThrow();
34+
});
35+
2836
it('accepts function evals filter', () => {
2937
const config = {
3038
agent: 'claude-code',

src/lib/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const experimentConfigSchema = z.object({
3434
'codex',
3535
'vercel-ai-gateway/opencode',
3636
]),
37-
model: z.string().optional(),
37+
model: z.union([z.string(), z.array(z.string())]).optional(),
3838
evals: z
3939
.union([z.string(), z.array(z.string()), z.function().args(z.string()).returns(z.boolean())])
4040
.optional(),

src/lib/runner.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
EvalRunData,
1111
EvalSummary,
1212
ExperimentResults,
13+
RunnableExperimentConfig,
1314
} from './types.js';
1415
import { getAgent } from './agents/index.js';
1516
import {
@@ -27,7 +28,7 @@ import {
2728
*/
2829
export interface RunExperimentOptions {
2930
/** Resolved experiment configuration */
30-
config: ResolvedExperimentConfig;
31+
config: RunnableExperimentConfig;
3132
/** Fixtures to run */
3233
fixtures: EvalFixture[];
3334
/** API key for the agent */
@@ -246,30 +247,44 @@ export async function runExperiment(
246247
/**
247248
* Run a single eval (for testing/debugging).
248249
*/
249-
export async function runSingleEval(
250+
export async function runSingleEval<T extends ResolvedExperimentConfig['model']>(
250251
fixture: EvalFixture,
251252
options: {
252253
agent?: ResolvedExperimentConfig['agent'];
253-
model: ResolvedExperimentConfig['model'];
254+
model: T;
254255
timeout: number;
255256
apiKey: string;
256257
setup?: ResolvedExperimentConfig['setup'];
257258
scripts?: string[];
258259
sandbox?: ResolvedExperimentConfig['sandbox'];
259260
verbose?: boolean;
260261
}
261-
): Promise<EvalRunData> {
262+
): Promise<T extends Array<unknown> ? EvalRunData[] : EvalRunData> {
262263
const agent = getAgent(options.agent ?? 'vercel-ai-gateway/claude-code');
263264

264-
const agentResult = await agent.run(fixture.path, {
265-
prompt: fixture.prompt,
266-
model: options.model,
267-
timeout: options.timeout * 1000,
268-
apiKey: options.apiKey,
269-
setup: options.setup,
270-
scripts: options.scripts,
271-
sandbox: options.sandbox,
272-
});
265+
const models: string[] = Array.isArray(options.model) ? options.model : [options.model];
266+
267+
const results: EvalRunData[] = [];
268+
269+
for (const model of models) {
270+
271+
const agentResult = await agent.run(fixture.path, {
272+
prompt: fixture.prompt,
273+
model,
274+
timeout: options.timeout * 1000,
275+
apiKey: options.apiKey,
276+
setup: options.setup,
277+
scripts: options.scripts,
278+
sandbox: options.sandbox,
279+
});
280+
281+
results.push(agentResultToEvalRunData(agentResult));
282+
}
283+
284+
// TODO: remove this on the next major and return an array directly...it's just here to prevent breaking changes
285+
if(!Array.isArray(options.model)) {
286+
return results[0] as T extends Array<unknown> ? EvalRunData[] : EvalRunData;
287+
}
273288

274-
return agentResultToEvalRunData(agentResult);
289+
return results as T extends Array<unknown> ? EvalRunData[] : EvalRunData;
275290
}

src/lib/types.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,10 @@ export interface ExperimentConfig {
6161
/** Which AI agent to use */
6262
agent: AgentType;
6363

64-
/** Which AI model the agent should use. Default is agent-specific: 'opus' for claude-code, 'openai/gpt-5.2-codex' for codex */
65-
model?: ModelTier;
64+
/** Which AI model the agent should use. Can be a single model or array of models to test.
65+
* If an array is provided, the experiment will run on each model.
66+
* Default is agent-specific: 'opus' for claude-code, 'openai/gpt-5.2-codex' for codex */
67+
model?: ModelTier | ModelTier[];
6668

6769
/** Which evals to run. Can be a string, array, or filter function. @default '*' (all evals) */
6870
evals?: string | string[] | EvalFilter;
@@ -90,6 +92,21 @@ export interface ExperimentConfig {
9092
* Resolved experiment config with all defaults applied.
9193
*/
9294
export interface ResolvedExperimentConfig {
95+
agent: AgentType;
96+
model: ModelTier | ModelTier[];
97+
evals: string | string[] | EvalFilter;
98+
runs: number;
99+
earlyExit: boolean;
100+
scripts: string[];
101+
timeout: number;
102+
setup?: SetupFunction;
103+
sandbox: SandboxBackend | 'auto';
104+
}
105+
106+
/**
107+
* Resolved experiment config with all defaults applied.
108+
*/
109+
export interface RunnableExperimentConfig {
93110
agent: AgentType;
94111
model: ModelTier;
95112
evals: string | string[] | EvalFilter;

0 commit comments

Comments
 (0)