use std::path::Path;
use serde::Serialize;
use serde_json::Value;
use crate::config::{load_config, BenchConfig};
use crate::net::{build_client, resolve_proxy};
use crate::llm::{chat_turn, env_key};
use crate::websearch::{fetch_page, format_results, web_search, web_tools};
pub struct Case {
#[allow(dead_code)] pub id: String,
pub prompt: String,
pub expected: Vec<String>,
pub choices: Vec<String>,
pub answer: Option<String>,
pub rubric: Option<String>,
}
pub fn parse_cases(path: &Path) -> Vec<Case> {
let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(_) => return Vec::new() };
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() { continue; }
let v: Value = match serde_json::from_str(line) { Ok(v) => v, Err(_) => continue };
let prompt = v.get("prompt").and_then(|x| x.as_str()).unwrap_or("").to_string();
if prompt.is_empty() { continue; }
let strs = |key: &str| v.get(key).and_then(|x| x.as_array())
.map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
.unwrap_or_default();
let expected = match v.get("expected") {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(_)) => strs("expected"),
_ => Vec::new(),
};
out.push(Case {
id: v.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string(),
prompt,
expected,
choices: strs("choices"),
answer: v.get("answer").and_then(|x| x.as_str()).map(String::from),
rubric: v.get("rubric").and_then(|x| x.as_str()).map(String::from),
});
}
out
}
pub fn contains_pass(output: &str, expected: &[String]) -> bool {
if expected.is_empty() { return false; }
let lo = output.to_lowercase();
expected.iter().any(|e| lo.contains(&e.to_lowercase()))
}
pub fn mcq_pass(output: &str, choices: &[String], answer: &str) -> bool {
let want = answer.trim().to_uppercase();
let n = choices.len().max(1);
for tok in output.split(|c: char| !c.is_ascii_alphabetic()) {
if tok.len() == 1 {
let up = tok.to_uppercase();
let idx = (up.as_bytes()[0] as i32) - b'A' as i32;
if idx >= 0 && (idx as usize) < n {
return up == want;
}
}
}
false
}
pub fn parse_judge_score(text: &str) -> Option<f64> {
let key = text.find("\"score\"")?;
let after = &text[key + "\"score\"".len()..];
let after = after.trim_start().strip_prefix(':')?.trim_start();
let num: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect();
let v: f64 = num.parse().ok()?;
Some(if v > 1.0 { (v / 5.0).min(1.0) } else { v.max(0.0) })
}
pub fn tokens_per_sec(tokens: usize, elapsed_ms: u128) -> f64 {
if elapsed_ms == 0 { return 0.0; }
tokens as f64 / (elapsed_ms as f64 / 1000.0)
}
#[derive(Serialize)]
pub struct BenchResult {
pub name: String,
pub method: String,
pub score: f64,
pub threshold: f64,
pub n_cases: usize,
pub mean_tok_s: f64,
pub mean_latency_ms: f64,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")] pub mean_turns: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] pub mean_tool_calls: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] pub mean_wall_ms: Option<f64>,
}
#[derive(Serialize)]
pub struct BenchReport {
pub model: String,
pub benchmarks: Vec<BenchResult>,
pub quality_score: u32,
pub overall: bool,
}
#[allow(clippy::too_many_arguments)] async fn chat_complete(
client: &reqwest::Client, base_url: &str, model: &str, key: &str,
system: Option<&str>, prompt: &str, temperature: f64, max_tokens: usize,
) -> std::io::Result<(String, usize, u128)> {
let mut messages = Vec::new();
if let Some(s) = system { messages.push(serde_json::json!({"role":"system","content":s})); }
messages.push(serde_json::json!({"role":"user","content":prompt}));
let (content, _calls, tokens, ms) = chat_turn(client, base_url, model, key, &messages, None, crate::llm::Sampling::recommended(temperature, max_tokens)).await?;
Ok((content.unwrap_or_default(), tokens, ms))
}
#[allow(clippy::too_many_arguments)]
async fn run_research_case(
client: &reqwest::Client, base_url: &str, model: &str, key: &str, temperature: f64, max_tokens: usize,
search: &crate::config::BenchSearchConfig, prompt: &str, max_turns: usize, fetch_bytes: u64, fetch_chars: usize,
stream: bool,
) -> std::io::Result<(String, u128, u128, usize, usize)> {
let tools = web_tools();
let mut messages = vec![serde_json::json!({"role":"user","content":prompt})];
let (mut model_ms, mut n_tool_calls) = (0u128, 0usize);
let wall = std::time::Instant::now();
let mut answer = String::new();
for turn in 0..max_turns {
let (content, calls) = if stream {
let t = std::time::Instant::now();
let out = crate::llm::chat_turn_stream(
client, base_url, model, key, &messages, Some(&tools), crate::llm::Sampling::recommended(temperature, max_tokens),
|piece| { eprint!("{piece}"); },
).await?;
model_ms += t.elapsed().as_millis();
out
} else {
let (content, calls, _tok, ms) = chat_turn(client, base_url, model, key, &messages, Some(&tools), crate::llm::Sampling::recommended(temperature, max_tokens)).await?;
model_ms += ms;
(content, calls)
};
if calls.is_empty() {
answer = content.unwrap_or_default();
return Ok((answer, model_ms, wall.elapsed().as_millis(), turn + 1, n_tool_calls));
}
messages.push(serde_json::json!({"role":"assistant","content":content,"tool_calls":
calls.iter().map(|c| serde_json::json!({"id":c.id,"type":"function","function":{"name":c.name,"arguments":c.arguments}})).collect::<Vec<_>>()}));
for c in &calls {
n_tool_calls += 1;
let args: serde_json::Value = serde_json::from_str(&c.arguments).unwrap_or(serde_json::json!({}));
let result = match c.name.as_str() {
"web_search" => format_results(&web_search(client, search.base_url.as_deref().filter(|s| !s.is_empty()), search.max_results, args.get("query").and_then(|q| q.as_str()).unwrap_or("")).await),
"fetch_page" => fetch_page(client, args.get("url").and_then(|u| u.as_str()).unwrap_or(""), &search.allow_hosts, fetch_bytes, fetch_chars).await,
other => format!("Unknown tool: {other}"),
};
messages.push(serde_json::json!({"role":"tool","tool_call_id":c.id,"content":result}));
}
}
Ok((answer, model_ms, wall.elapsed().as_millis(), max_turns, n_tool_calls))
}
pub async fn run_bench(repo_root: &Path, _strict: bool, stream: bool) -> std::io::Result<BenchReport> {
let cfg = load_config(&repo_root.join(crate::config::CONFIG_FILE));
let bench: BenchConfig = cfg.bench.ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::InvalidInput, "no [bench] table in kibble.toml"))?;
if bench.benchmark.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "no [[bench.benchmark]] configured"));
}
let proxy = resolve_proxy(cfg.network.proxy.as_deref(), |k| std::env::var(k).ok());
let client = build_client(proxy.as_deref())
.map_err(|e| std::io::Error::other(format!("http client: {e}")))?;
let model_key = env_key(&["BENCH_API_KEY", "OPENAI_API_KEY"]);
let judge_key = env_key(&["JUDGE_API_KEY", "OPENAI_API_KEY"]);
let mut results = Vec::new();
for b in &bench.benchmark {
let cases = parse_cases(&repo_root.join(&b.path));
let (mut passed_sum, mut tok_s_sum, mut ms_sum) = (0.0f64, 0.0f64, 0.0f64);
let (mut turns_sum, mut calls_sum, mut wall_sum) = (0.0, 0.0, 0.0);
let n = cases.len();
for c in &cases {
if b.method == "research" {
let search = bench.search.as_ref().ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::InvalidInput, format!("benchmark '{}' is method=research but no [bench.search]", b.name)))?;
if stream { eprintln!("\n── {} ──", b.name); }
let res = run_research_case(&client, &bench.model.base_url, &bench.model.model, &model_key,
bench.model.temperature, bench.model.max_tokens, search, &c.prompt,
b.max_turns.unwrap_or(4), b.fetch_bytes.unwrap_or(2_000_000), b.fetch_chars.unwrap_or(4000), stream).await;
match res {
Ok((answer, model_ms, wall_ms, turns, calls)) => {
ms_sum += model_ms as f64;
wall_sum += wall_ms as f64;
turns_sum += turns as f64;
calls_sum += calls as f64;
let scorer = b.scorer.as_deref().unwrap_or("contains");
passed_sum += match scorer {
"judge" => {
let judge = bench.judge.as_ref().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("research '{}' scorer=judge but no [bench.judge]", b.name)))?;
let rubric = c.rubric.clone().unwrap_or_else(|| "Rate the answer's quality.".into());
let jp = format!("Question:\n{}\n\nAnswer:\n{}\n\nRubric: {}\nReply with ONLY JSON: {{\"score\": <0.0-1.0>}}.", c.prompt, answer, rubric);
match chat_complete(&client, &judge.base_url, &judge.model, &judge_key, None, &jp, 0.0, 64).await {
Ok((jout, _, _)) => parse_judge_score(&jout).unwrap_or(0.0),
Err(_) => 0.0,
}
}
_ => if contains_pass(&answer, &c.expected) { 1.0 } else { 0.0 },
};
}
Err(_) => { }
}
continue;
}
let prompt = if b.method == "mcq" && !c.choices.is_empty() {
let opts: String = c.choices.iter().enumerate()
.map(|(i, ch)| format!("{}. {}", (b'A' + i as u8) as char, ch)).collect::<Vec<_>>().join("\n");
format!("{}\n{}\nAnswer with the letter only.", c.prompt, opts)
} else { c.prompt.clone() };
let (out, tokens, ms) = chat_complete(&client, &bench.model.base_url, &bench.model.model,
&model_key, b.system_prompt.as_deref(), &prompt, bench.model.temperature, bench.model.max_tokens).await?;
tok_s_sum += tokens_per_sec(tokens, ms);
ms_sum += ms as f64;
let case_score = match b.method.as_str() {
"contains" => if contains_pass(&out, &c.expected) { 1.0 } else { 0.0 },
"mcq" => if mcq_pass(&out, &c.choices, c.answer.as_deref().unwrap_or("")) { 1.0 } else { 0.0 },
"judge" => {
let judge = bench.judge.as_ref().ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::InvalidInput, format!("benchmark '{}' uses method=judge but no [bench.judge]", b.name)))?;
let rubric = c.rubric.clone().unwrap_or_else(|| "Rate the answer's quality.".to_string());
let jp = format!("Question:\n{}\n\nAnswer:\n{}\n\nRubric: {}\nReply with ONLY JSON: {{\"score\": <0.0-1.0>}}.", c.prompt, out, rubric);
let (jout, _, _) = chat_complete(&client, &judge.base_url, &judge.model, &judge_key, None, &jp, 0.0, 64).await?;
parse_judge_score(&jout).unwrap_or(0.0)
}
other => return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("unknown method '{other}'"))),
};
passed_sum += case_score;
}
let score = if n == 0 { 0.0 } else { passed_sum / n as f64 };
let mean_tok_s = if n == 0 { 0.0 } else { tok_s_sum / n as f64 };
let mean_latency_ms = if n == 0 { 0.0 } else { ms_sum / n as f64 };
let ok = n > 0 && score >= b.threshold
&& b.min_tokens_per_sec.map(|m| mean_tok_s >= m).unwrap_or(true);
let is_research = b.method == "research";
let mk = |sum: f64| if is_research && n > 0 { Some(sum / n as f64) } else { None };
results.push(BenchResult { name: b.name.clone(), method: b.method.clone(), score,
threshold: b.threshold, n_cases: n, mean_tok_s, mean_latency_ms, ok,
mean_turns: mk(turns_sum), mean_tool_calls: mk(calls_sum), mean_wall_ms: mk(wall_sum) });
}
let ok_count = results.iter().filter(|r| r.ok).count();
let overall = !results.is_empty() && ok_count == results.len();
let quality_score = if results.is_empty() { 0 } else { (100.0 * ok_count as f64 / results.len() as f64).round() as u32 };
let report = BenchReport { model: bench.model.model.clone(), benchmarks: results, quality_score, overall };
let out_dir = repo_root.join(&bench.out);
std::fs::create_dir_all(&out_dir)?;
std::fs::write(out_dir.join("report.json"), serde_json::to_string_pretty(&report)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?)?;
std::fs::write(out_dir.join("report.md"), render_md(&report))?;
Ok(report)
}
fn render_md(r: &BenchReport) -> String {
let mut s = format!("# Benchmarks — {} / 100 — {} (model `{}`)\n\n", r.quality_score,
if r.overall { "PASS" } else { "FAIL" }, r.model);
s.push_str("| benchmark | method | score | threshold | tok/s | latency ms | turns | tool-calls | wall ms | status |\n|---|---|---|---|---|---|---|---|---|---|\n");
for b in &r.benchmarks {
let opt = |o: Option<f64>| o.map(|v| format!("{v:.1}")).unwrap_or_else(|| "-".into());
s.push_str(&format!("| {} | {} | {:.3} | {:.3} | {:.1} | {:.0} | {} | {} | {} | {} |\n",
b.name, b.method, b.score, b.threshold, b.mean_tok_s, b.mean_latency_ms,
opt(b.mean_turns), opt(b.mean_tool_calls), opt(b.mean_wall_ms), if b.ok { "ok" } else { "FAIL" }));
}
let fails: Vec<&str> = r.benchmarks.iter().filter(|b| !b.ok).map(|b| b.name.as_str()).collect();
if !fails.is_empty() { s.push_str(&format!("\n## Failing\n{}\n", fails.iter().map(|f| format!("- {f}")).collect::<Vec<_>>().join("\n"))); }
s
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn mock_openai(content: &str, n: usize) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let body = format!(
"{{\"choices\":[{{\"message\":{{\"content\":{}}}}}],\"usage\":{{\"completion_tokens\":5}}}}",
serde_json::to_string(content).unwrap()
);
std::thread::spawn(move || {
for _ in 0..n {
if let Ok((mut s, _)) = listener.accept() {
let mut buf = [0u8; 4096]; let _ = s.read(&mut buf);
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}", body.len(), body);
let _ = s.write_all(resp.as_bytes());
}
}
});
format!("http://{addr}/v1")
}
fn mock_tool_call(n: usize) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let body = r#"{"choices":[{"message":{"content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"web_search","arguments":"{\"query\":\"x\"}"}}]}}],"usage":{"completion_tokens":3}}"#.to_string();
std::thread::spawn(move || { use std::io::{Read,Write}; for _ in 0..n { if let Ok((mut s,_))=listener.accept(){ let mut b=[0u8;2048]; let _=s.read(&mut b); let resp=format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body); let _=s.write_all(resp.as_bytes()); }}});
format!("http://{addr}/v1")
}
#[tokio::test]
async fn chat_turn_parses_tool_calls_and_content() {
let client = crate::net::build_client(None).unwrap();
let base = mock_tool_call(1);
let msgs = vec![serde_json::json!({"role":"user","content":"hi"})];
let (content, calls, _t, _ms) = chat_turn(&client, &base, "m", "", &msgs, Some(&web_tools()), crate::llm::Sampling::recommended(0.0, 128)).await.unwrap();
assert!(content.is_none());
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "web_search");
assert!(calls[0].arguments.contains("query"));
let base2 = mock_openai("Paris.", 1);
let (ans, _tok, _ms) = chat_complete(&client, &base2, "m", "", None, "capital?", 0.0, 64).await.unwrap();
assert_eq!(ans, "Paris.");
}
#[tokio::test]
async fn run_bench_scores_and_gates() {
let root = std::env::temp_dir().join(format!("kibble_bench_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("benchmarks")).unwrap();
std::fs::write(root.join("benchmarks/qa.jsonl"),
"{\"id\":\"1\",\"prompt\":\"capital of france\",\"expected\":[\"Paris\"]}\n").unwrap();
let base = mock_openai("Paris is the capital.", 4);
std::fs::write(root.join(crate::config::CONFIG_FILE), format!(
"[bench]\n[bench.model]\nbase_url=\"{base}\"\nmodel=\"m\"\n[[bench.benchmark]]\nname=\"qa\"\npath=\"benchmarks/qa.jsonl\"\nmethod=\"contains\"\nthreshold=0.5\n")).unwrap();
let rep = run_bench(&root, false, false).await.unwrap();
assert_eq!(rep.benchmarks.len(), 1);
assert!((rep.benchmarks[0].score - 1.0).abs() < 1e-9);
assert!(rep.benchmarks[0].ok && rep.overall);
assert!(root.join("bench/report/report.json").is_file());
let streamed = run_bench(&root, false, true).await.unwrap();
assert_eq!(streamed.benchmarks[0].score, rep.benchmarks[0].score);
assert_eq!(streamed.quality_score, rep.quality_score);
let base2 = mock_openai("London.", 4);
std::fs::write(root.join(crate::config::CONFIG_FILE), format!(
"[bench]\n[bench.model]\nbase_url=\"{base2}\"\nmodel=\"m\"\n[[bench.benchmark]]\nname=\"qa\"\npath=\"benchmarks/qa.jsonl\"\nmethod=\"contains\"\nthreshold=0.5\n")).unwrap();
let rep2 = run_bench(&root, false, false).await.unwrap();
assert!(!rep2.benchmarks[0].ok && !rep2.overall);
}
#[test]
fn contains_pass_is_case_insensitive_any() {
assert!(contains_pass("The capital is Paris.", &["paris".into()]));
assert!(contains_pass("Answer: 42", &["nope".into(), "42".into()]));
assert!(!contains_pass("nope", &["paris".into()]));
assert!(!contains_pass("x", &[]));
}
#[test]
fn mcq_pass_extracts_letter() {
let choices = vec!["3".to_string(), "4".to_string(), "5".to_string()];
assert!(mcq_pass("The answer is B.", &choices, "B"));
assert!(mcq_pass("B", &choices, "B"));
assert!(!mcq_pass("A", &choices, "B"));
assert!(!mcq_pass("no letter here", &choices, "B"));
}
#[test]
fn parse_judge_score_reads_and_normalizes() {
assert_eq!(parse_judge_score(r#"{"score": 0.8}"#), Some(0.8));
assert_eq!(parse_judge_score(r#"here is my rating {"score": 4} out of 5"#), Some(0.8)); assert_eq!(parse_judge_score("no json"), None);
}
#[test]
fn tokens_per_sec_math() {
assert!((tokens_per_sec(100, 1000) - 100.0).abs() < 1e-9);
assert_eq!(tokens_per_sec(10, 0), 0.0); }
fn mock_seq(bodies: Vec<String>) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read, Write};
for body in bodies {
if let Ok((mut s, _)) = listener.accept() {
let mut b = [0u8; 4096]; let _ = s.read(&mut b);
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}", body.len(), body);
let _ = s.write_all(resp.as_bytes());
}
}
});
format!("http://{addr}/v1")
}
#[tokio::test]
async fn run_bench_research_tool_loop() {
let root = std::env::temp_dir().join(format!("kibble_research_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("benchmarks")).unwrap();
std::fs::write(root.join("benchmarks/r.jsonl"),
"{\"id\":\"1\",\"prompt\":\"capital of france\",\"expected\":[\"Paris\"]}\n").unwrap();
let tool_body = r#"{"choices":[{"message":{"content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"web_search","arguments":"{\"query\":\"capital of france\"}"}}]}}],"usage":{"completion_tokens":3}}"#.to_string();
let final_body = r#"{"choices":[{"message":{"content":"Paris is the capital."}}],"usage":{"completion_tokens":5}}"#.to_string();
let model_base = mock_seq(vec![tool_body, final_body]);
let sx_body = r#"{"results":[{"title":"France","url":"https://fr","content":"Paris is the capital of France."}]}"#.to_string();
let search_base = { let a = mock_seq(vec![sx_body]); a.trim_end_matches("/v1").to_string() };
std::fs::write(root.join(crate::config::CONFIG_FILE), format!(
"[bench]\n[bench.model]\nbase_url=\"{model_base}\"\nmodel=\"m\"\n[bench.search]\nbase_url=\"{search_base}\"\nallow_hosts=[\"127.0.0.1\"]\n[[bench.benchmark]]\nname=\"r\"\npath=\"benchmarks/r.jsonl\"\nmethod=\"research\"\nscorer=\"contains\"\nthreshold=0.5\nmax_turns=4\n")).unwrap();
let rep = run_bench(&root, false, false).await.unwrap();
assert_eq!(rep.benchmarks.len(), 1);
assert!((rep.benchmarks[0].score - 1.0).abs() < 1e-9, "final answer contains Paris → pass");
assert_eq!(rep.benchmarks[0].mean_tool_calls, Some(1.0));
assert!(rep.benchmarks[0].mean_wall_ms.is_some());
}
#[test]
fn parse_cases_reads_jsonl() {
let dir = std::env::temp_dir().join(format!("kibble_cases_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("c.jsonl");
std::fs::write(&p, "{\"id\":\"1\",\"prompt\":\"q\",\"expected\":[\"a\"]}\nnot json\n{\"prompt\":\"m\",\"choices\":[\"x\",\"y\"],\"answer\":\"B\"}\n").unwrap();
let cases = parse_cases(&p);
assert_eq!(cases.len(), 2); assert_eq!(cases[0].expected, vec!["a".to_string()]);
assert_eq!(cases[1].answer.as_deref(), Some("B"));
}
}