use clap::{Args, ValueEnum};
use std::process::Command;
#[derive(Clone, Debug, ValueEnum, Default)]
pub enum Mode {
Container,
#[default]
Compose,
Job,
}
#[derive(Args)]
pub struct RunArgs {
#[arg(value_name = "BENCHMARK")]
benchmark_positional: Option<String>,
#[arg(long = "benchmark")]
benchmark_flag: Option<String>,
#[arg(long, value_enum, default_value_t = Mode::Compose)]
mode: Mode,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
task_id: Option<String>,
#[arg(long)]
benchmark_tag: Option<String>,
#[arg(long)]
agent_tag: Option<String>,
#[arg(long)]
model_tag: Option<String>,
#[arg(long)]
timeout: Option<u32>,
#[arg(long)]
max_budget: Option<f64>,
#[arg(long)]
local: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, short = 'n')]
namespace: Option<String>,
#[arg(long)]
overlay: Option<String>,
}
const GATEWAY_CRED_VARS: &[&str] = &["OPENAI_API_KEY", "OPENAI_API_BASE"];
const CHART_NAME: &str = "eval";
const CHART_VERSION: &str = "0.1.0";
pub fn execute(registry: &str, args: RunArgs) -> Result<(), String> {
let benchmark = args
.benchmark_flag
.clone()
.or_else(|| args.benchmark_positional.clone())
.ok_or_else(|| "benchmark required (positional or --benchmark)".to_string())?;
let mut envs: Vec<(&str, String)> = vec![
("EVAL_REGISTRY", registry.to_string()),
("EVAL_BENCHMARK", benchmark.clone()),
];
if let Some(ref v) = args.agent {
envs.push(("EVAL_AGENT", v.clone()));
}
if let Some(ref v) = args.model {
envs.push(("EVAL_MODEL", v.clone()));
}
if let Some(ref v) = args.task_id {
envs.push(("EVAL_TASK_ID", v.clone()));
}
if let Some(ref v) = args.benchmark_tag {
envs.push(("EVAL_BENCHMARK_TAG", v.clone()));
}
if let Some(ref v) = args.agent_tag {
envs.push(("EVAL_AGENT_TAG", v.clone()));
}
if let Some(ref v) = args.model_tag {
envs.push(("EVAL_MODEL_TAG", v.clone()));
}
if let Some(timeout) = args.timeout {
envs.push(("EVAL_TIMEOUT", timeout.to_string()));
}
if let Some(budget) = args.max_budget {
envs.push(("EVAL_MODEL_MAX_BUDGET", budget.to_string()));
}
if args.overlay.is_some() && !matches!(args.mode, Mode::Job) {
return Err("--overlay applies only to `--mode job`".into());
}
match args.mode {
Mode::Compose => run_compose(registry, &benchmark, &envs, args.local, args.dry_run),
Mode::Container => run_container(
registry,
&benchmark,
&args.agent,
&envs,
args.local,
args.dry_run,
),
Mode::Job => run_job(registry, &benchmark, &args, &envs),
}
}
fn run_compose(
registry: &str,
benchmark: &str,
envs: &[(&str, String)],
local: bool,
dry_run: bool,
) -> Result<(), String> {
let compose_ref = if local {
format!("./benchmarks/{benchmark}/compose.yaml")
} else {
format!("oci://{registry}/evaluate")
};
let env_str = envs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ");
eprintln!("$ {env_str} docker compose -f {compose_ref} up -y --abort-on-container-exit");
if dry_run {
eprintln!("(--dry-run: showing resolved compose config, not running)");
let mut cmd = Command::new("docker");
cmd.arg("compose").arg("-f").arg(&compose_ref).arg("config");
for (k, v) in envs {
cmd.env(k, v);
}
let status = cmd
.status()
.map_err(|e| format!("failed to run docker compose config: {e}"))?;
if !status.success() {
return Err(format!("docker compose config failed with {status}"));
}
return Ok(());
}
let mut cmd = Command::new("docker");
cmd.arg("compose").arg("-f").arg(&compose_ref);
cmd.arg("up").arg("-y").arg("--abort-on-container-exit");
for (k, v) in envs {
cmd.env(k, v);
}
let status = cmd
.status()
.map_err(|e| format!("failed to run docker compose: {e}"))?;
if !status.success() {
return Err(format!("docker compose failed with {status}"));
}
Ok(())
}
fn run_container(
registry: &str,
benchmark: &str,
agent: &Option<String>,
envs: &[(&str, String)],
local: bool,
dry_run: bool,
) -> Result<(), String> {
let agent = agent
.clone()
.ok_or_else(|| "--agent is required in container mode".to_string())?;
let task_id = envs
.iter()
.find(|(k, _)| *k == "EVAL_TASK_ID")
.map(|(_, v)| v.clone());
let per_task = eval_containers::benchmark::is_per_task_by_name(benchmark);
if per_task && task_id.is_none() {
return Err(format!(
"{benchmark} is a per-task benchmark — pass --task-id <id> in container mode"
));
}
let local_tag = match (per_task, task_id.as_deref()) {
(true, Some(t)) => format!("evals/{benchmark}-{t}--{agent}:local"),
_ => format!("evals/{benchmark}--{agent}:local"),
};
let image = if local {
let dockerfile = format!("./benchmarks/{benchmark}/container.Dockerfile");
let shown_arg = task_id
.as_deref()
.map(|t| format!(" --build-arg EVAL_TASK_ID={t}"))
.unwrap_or_default();
eprintln!("$ docker build -f {dockerfile}{shown_arg} -t {local_tag} .");
let mut build = Command::new("docker");
build.arg("build").arg("-f").arg(&dockerfile);
if let Some(t) = task_id.as_deref() {
build.arg("--build-arg").arg(format!("EVAL_TASK_ID={t}"));
}
build.arg("-t").arg(&local_tag).arg(".");
let status = build
.status()
.map_err(|e| format!("failed to docker build: {e}"))?;
if !status.success() {
return Err(format!("docker build failed with {status}"));
}
local_tag
} else if per_task {
eval_containers::naming::eval_task_image(
registry,
benchmark,
task_id.as_deref().unwrap(),
&agent,
"latest",
)
} else {
eval_containers::naming::eval_image(registry, benchmark, &agent, "latest")
};
let env_str = envs
.iter()
.map(|(k, v)| format!("-e {k}={v}"))
.collect::<Vec<_>>()
.join(" ");
eprintln!("$ docker run --rm {env_str} -v output:/output {image}");
if dry_run {
eprintln!("(--dry-run: stopping before docker run)");
return Ok(());
}
let mut cmd = Command::new("docker");
cmd.arg("run").arg("--rm");
for (k, v) in envs {
cmd.arg("-e").arg(format!("{k}={v}"));
}
for var in GATEWAY_CRED_VARS {
if std::env::var_os(var).is_some() {
cmd.arg("-e").arg(var);
}
}
cmd.arg("-v").arg("output:/output");
cmd.arg(&image);
let status = cmd
.status()
.map_err(|e| format!("failed to docker run: {e}"))?;
if !status.success() {
return Err(format!("docker run failed with {status}"));
}
Ok(())
}
fn run_job(
registry: &str,
benchmark: &str,
args: &RunArgs,
_envs: &[(&str, String)],
) -> Result<(), String> {
let agent = args.agent.as_deref().unwrap_or("claude-code");
let task = args.task_id.as_deref().unwrap_or("0");
let chart = if args.local {
let local = "./benchmarks/_chart".to_string();
if !std::path::Path::new(&local).exists() {
return Err("--local needs ./benchmarks/_chart; run from the repo root".into());
}
local
} else {
format!("oci://{registry}/charts/{CHART_NAME}")
};
let release =
eval_containers::naming::release_name(&format!("{benchmark}-{agent}-task-{task}"));
let mut helm: Vec<String> = vec!["template".into(), release, chart];
if !args.local {
helm.push("--version".into());
helm.push(CHART_VERSION.into());
}
if let Some(ov) = &args.overlay {
if !std::path::Path::new(ov).exists() {
return Err(format!(
"overlay values file not found: {ov} (a platform overlay is now a \
Helm values file, e.g. deploy/values-openshift.yaml)"
));
}
helm.push("-f".into());
helm.push(ov.clone());
}
let mut sets: Vec<String> = vec![
format!("benchmark={benchmark}"),
format!("registry={registry}"),
format!("agent={agent}"),
format!("task={task}"),
];
if eval_containers::benchmark::is_per_task_by_name(benchmark) {
sets.push("perTask=true".into());
}
if let Some(m) = &args.model {
sets.push(format!("evalModel={m}"));
sets.push(format!("model={m}"));
}
if let Some(t) = args.timeout {
sets.push(format!("timeout={t}"));
}
if let Some(t) = &args.model_tag {
sets.push(format!("gatewayTag={t}"));
}
if let Some(t) = args.agent_tag.as_ref().or(args.benchmark_tag.as_ref()) {
sets.push(format!("runnerTag={t}"));
}
if let Some(b) = args.max_budget {
sets.push(format!("maxBudget={b}"));
}
for s in &sets {
helm.push("--set".into());
helm.push(s.clone());
}
let mut apply_args: Vec<String> = vec!["apply".into()];
if args.dry_run {
apply_args.push("--dry-run=server".into());
}
if let Some(ns) = &args.namespace {
apply_args.push("-n".into());
apply_args.push(ns.clone());
}
eprintln!(
"$ helm {} | kubectl {} -f -",
helm.join(" "),
apply_args.join(" ")
);
eprintln!("(Note: cluster needs `eval-secrets` Secret with OPENAI_API_KEY+OPENAI_API_BASE.)");
let helm_out = Command::new("helm")
.args(&helm)
.output()
.map_err(|e| format!("failed to run helm template (is helm installed?): {e}"))?;
if !helm_out.status.success() {
return Err(format!(
"helm template failed: {}",
String::from_utf8_lossy(&helm_out.stderr)
));
}
use std::process::Stdio;
let mut apply_cmd = Command::new("kubectl");
for a in &apply_args {
apply_cmd.arg(a);
}
apply_cmd.args(["-f", "-"]);
let mut apply = apply_cmd
.stdin(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to spawn kubectl apply: {e}"))?;
{
use std::io::Write;
apply
.stdin
.as_mut()
.unwrap()
.write_all(&helm_out.stdout)
.map_err(|e| format!("failed to pipe manifest to kubectl apply: {e}"))?;
}
let status = apply
.wait()
.map_err(|e| format!("failed to wait on kubectl apply: {e}"))?;
if !status.success() {
return Err(format!("kubectl apply failed with {status}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{CHART_NAME, CHART_VERSION};
#[test]
fn chart_consts_match_chart_yaml() {
let yaml = std::fs::read_to_string("benchmarks/_chart/Chart.yaml")
.expect("read benchmarks/_chart/Chart.yaml from the repo root");
assert!(
yaml.lines()
.any(|l| l.trim() == format!("name: {CHART_NAME}")),
"CHART_NAME ({CHART_NAME}) must match Chart.yaml `name`"
);
assert!(
yaml.lines()
.any(|l| l.trim() == format!("version: {CHART_VERSION}")),
"CHART_VERSION ({CHART_VERSION}) must match Chart.yaml `version`"
);
}
}