use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result};
use futures::StreamExt;
use reqwest::Client;
use serde_json::json;
use tracing::{debug, error, info, warn};
use super::config::HarnessConfig;
use super::report::HarnessReport;
use super::task::{BenchTask, StreamResult};
pub struct HarnessRunner {
config: HarnessConfig,
client: Client,
sequence: Arc<AtomicU64>,
}
impl HarnessRunner {
pub fn new(config: HarnessConfig) -> Result<Self> {
config
.validate()
.map_err(|e| anyhow::anyhow!("Invalid config: {e}"))?;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(config.timeout_secs))
.connect_timeout(std::time::Duration::from_secs(15))
.pool_max_idle_per_host(config.max_concurrent)
.build()
.context("Failed to build HTTP client")?;
Ok(Self {
config,
client,
sequence: Arc::new(AtomicU64::new(0)),
})
}
pub async fn run(&self, tasks: Vec<BenchTask>) -> Result<HarnessReport> {
let run_start = Instant::now();
let total_tasks = tasks.len();
info!(
"Starting benchmark: {} tasks, {} concurrent streams, model={}",
total_tasks, self.config.max_concurrent, self.config.model,
);
let mut stream = futures::stream::iter(tasks)
.map(|task| {
let client = self.client.clone();
let config = self.config.clone();
let seq = self.sequence.clone();
async move {
let stream_id = seq.fetch_add(1, Ordering::Relaxed) as usize;
execute_task(&client, &config, &task, stream_id).await
}
})
.buffer_unordered(self.config.max_concurrent.max(1));
let mut results = Vec::with_capacity(total_tasks);
let mut completed = 0usize;
while let Some(result) = stream.next().await {
completed += 1;
let status = if result.success { "OK" } else { "FAIL" };
let score_str = result
.eval
.as_ref()
.map(|e| format!("{:.0}%", e.score * 100.0))
.unwrap_or_else(|| "N/A".into());
eprintln!(
" [{completed}/{total_tasks}] {} — {status} | {score_str} | {}ms | {}+{} tokens",
result.task_id, result.latency_ms, result.prompt_tokens, result.completion_tokens,
);
results.push(result);
}
let total_duration = run_start.elapsed();
let report =
HarnessReport::from_results(&self.config, results, total_duration.as_secs_f64());
info!(
"Benchmark complete: {:.1}s | {}/{} passed | {:.0} tok/s",
report.total_duration_secs,
report.tasks_passed,
report.tasks_total,
report.tokens_per_sec,
);
Ok(report)
}
}
async fn execute_task(
client: &Client,
config: &HarnessConfig,
task: &BenchTask,
stream_id: usize,
) -> StreamResult {
let start = Instant::now();
let url = format!("{}/chat/completions", config.endpoint.trim_end_matches('/'));
if let Err(e) = crate::config::api_key::assert_credential_endpoint_safe(
&config.endpoint,
config.api_key.is_some(),
) {
return StreamResult {
task_id: task.id.clone(),
stream_id,
success: false,
transport_succeeded: false,
response: String::new(),
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: start.elapsed().as_millis() as u64,
eval: None,
error: Some(e.to_string()),
};
}
let mut body = json!({
"model": config.model,
"messages": task.messages,
"max_tokens": config.max_tokens,
"temperature": config.temperature,
"stream": false,
});
if let (Some(body_obj), Some(extra_obj)) = (body.as_object_mut(), config.extra_body.as_object())
{
for (k, v) in extra_obj {
body_obj.insert(k.clone(), v.clone());
}
}
debug!(task_id = %task.id, stream_id, "Sending request");
let max_attempts = (config.max_retries + 1).max(1);
let mut attempt = 0u32;
let mut delay_ms = config.retry_delay_ms.max(1);
let deadline = start + std::time::Duration::from_secs(config.timeout_secs);
let (status, body_text) = loop {
if std::time::Instant::now() >= deadline {
return StreamResult {
task_id: task.id.clone(),
stream_id,
success: false,
transport_succeeded: false,
response: String::new(),
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: start.elapsed().as_millis() as u64,
eval: None,
error: Some(format!("Task deadline exceeded ({}s)", config.timeout_secs)),
};
}
attempt += 1;
let mut request = client.post(&url).json(&body);
if let Some(key) = config.api_key.as_deref() {
request = request.bearer_auth(key);
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let send_result = match tokio::time::timeout(remaining, request.send()).await {
Ok(r) => r,
Err(_elapsed) => {
return StreamResult {
task_id: task.id.clone(),
stream_id,
success: false,
transport_succeeded: false,
response: String::new(),
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: start.elapsed().as_millis() as u64,
eval: None,
error: Some(format!("Task deadline exceeded ({}s)", config.timeout_secs)),
};
}
};
match send_result {
Ok(resp) => {
let status = resp.status();
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let body_text = match tokio::time::timeout(remaining, resp.text()).await {
Ok(r) => r.unwrap_or_default(),
Err(_elapsed) => {
return StreamResult {
task_id: task.id.clone(),
stream_id,
success: false,
transport_succeeded: false,
response: String::new(),
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: start.elapsed().as_millis() as u64,
eval: None,
error: Some(format!(
"Task deadline exceeded ({}s)",
config.timeout_secs
)),
};
}
};
if status.is_success() {
break (status, body_text);
}
if is_retryable_status(status) && attempt < max_attempts {
warn!(
task_id = %task.id,
stream_id,
attempt,
max_attempts,
"Retryable HTTP {status}; retrying after {delay_ms}ms"
);
let backoff = std::time::Duration::from_millis(delay_ms)
.min(deadline.saturating_duration_since(std::time::Instant::now()));
tokio::time::sleep(backoff).await;
delay_ms = (delay_ms * 2).min(10_000);
continue;
}
break (status, body_text);
}
Err(e) => {
if attempt < max_attempts {
warn!(
task_id = %task.id,
stream_id,
attempt,
max_attempts,
"Request failed: {e}; retrying after {delay_ms}ms"
);
let backoff = std::time::Duration::from_millis(delay_ms)
.min(deadline.saturating_duration_since(std::time::Instant::now()));
tokio::time::sleep(backoff).await;
delay_ms = (delay_ms * 2).min(10_000);
continue;
}
error!(task_id = %task.id, "Request failed: {e}");
return StreamResult {
task_id: task.id.clone(),
stream_id,
success: false,
response: String::new(),
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: start.elapsed().as_millis() as u64,
eval: None,
transport_succeeded: false,
error: Some(format!("Request error: {e}")),
};
}
}
};
if !status.is_success() {
return StreamResult {
task_id: task.id.clone(),
stream_id,
success: false,
response: body_text.clone(),
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: start.elapsed().as_millis() as u64,
eval: None,
transport_succeeded: false,
error: Some(format!("HTTP {status}: {body_text}")),
};
}
let parsed: serde_json::Value = match serde_json::from_str(&body_text) {
Ok(v) => v,
Err(e) => {
return StreamResult {
task_id: task.id.clone(),
stream_id,
success: false,
response: body_text,
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: start.elapsed().as_millis() as u64,
eval: None,
transport_succeeded: false,
error: Some(format!("JSON parse error: {e}")),
};
}
};
let content = parsed["choices"][0]["message"]["content"]
.as_str()
.or_else(|| parsed["choices"][0]["message"]["reasoning_content"].as_str())
.or_else(|| parsed["choices"][0]["message"]["reasoning"].as_str())
.unwrap_or("")
.to_string();
let usage = &parsed["usage"];
if usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.is_none()
{
warn!(
task_id = %task.id,
stream_id,
"response has no usage.completion_tokens; throughput will undercount this request"
);
}
let prompt_tokens = usage["prompt_tokens"].as_u64().unwrap_or(0);
let completion_tokens = usage["completion_tokens"].as_u64().unwrap_or(0);
let latency_ms = start.elapsed().as_millis() as u64;
let eval = task.evaluator.evaluate(&content);
let success = eval.passed;
debug!(
task_id = %task.id,
stream_id,
score = eval.score,
latency_ms,
"Task complete"
);
StreamResult {
task_id: task.id.clone(),
stream_id,
success,
transport_succeeded: true,
response: content,
prompt_tokens,
completion_tokens,
latency_ms,
eval: Some(eval),
error: None,
}
}
fn is_retryable_status(status: reqwest::StatusCode) -> bool {
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
}
#[cfg(test)]
#[path = "../../tests/unit/bench_harness/runner/runner_test.rs"]
mod tests;