use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::agent::events::AgentEvent;
use crate::execution_context::ExecutionContext;
use crate::llm::LlmProvider;
use crate::tool::Tool;
use super::builder::BrowserAgentBuilder;
#[derive(Default)]
struct RunTrace {
turns: usize,
tool_calls: usize,
tools: Vec<String>,
input_tokens: u32,
output_tokens: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Oracle {
FinalPageContains(String),
AgentAnswerContains(String),
UrlContains(String),
}
impl Oracle {
pub fn grade(&self, snapshot: &str, answer: &str) -> bool {
match self {
Oracle::FinalPageContains(s) => snapshot.contains(s.as_str()),
Oracle::AgentAnswerContains(s) => answer.to_lowercase().contains(&s.to_lowercase()),
Oracle::UrlContains(s) => {
snapshot_url(snapshot).is_some_and(|u| u.contains(s.as_str()))
}
}
}
}
fn snapshot_url(snapshot: &str) -> Option<&str> {
let line = snapshot.lines().find(|l| l.contains("RootWebArea"))?;
let key = "url=\"";
let start = line.find(key)? + key.len();
let rest = &line[start..];
let end = rest.find('"')?;
Some(&rest[..end])
}
#[derive(Debug, Clone)]
pub struct BenchTask {
pub name: String,
pub difficulty: String,
pub allow_hosts: Vec<String>,
pub instruction: String,
pub oracle: Oracle,
pub max_turns: usize,
pub tools: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct BenchResult {
pub name: String,
pub difficulty: String,
pub passed: bool,
pub tool_calls: usize,
pub input_tokens: u32,
pub output_tokens: u32,
pub cost_usd: Option<f64>,
pub millis: u128,
pub attempts: usize,
pub turns: usize,
pub trace: Vec<String>,
pub answer_excerpt: String,
pub answer: String,
pub final_snapshot: String,
pub error: Option<String>,
}
pub async fn run_bench<P: LlmProvider>(
provider: Arc<P>,
tools: Vec<Arc<dyn Tool>>,
tasks: &[BenchTask],
) -> Vec<BenchResult> {
run_bench_with_retries(provider, tools, tasks, 1).await
}
pub async fn run_bench_with_retries<P: LlmProvider>(
provider: Arc<P>,
tools: Vec<Arc<dyn Tool>>,
tasks: &[BenchTask],
max_attempts: usize,
) -> Vec<BenchResult> {
let ctx = ExecutionContext::default();
let snapshot_tool = tools
.iter()
.find(|t| t.definition().name == "take_snapshot")
.cloned();
let cap = max_attempts.max(1);
let mut results = Vec::with_capacity(tasks.len());
for task in tasks {
let mut r = BenchResult::default();
for attempt in 1..=cap {
r = run_task_once(&provider, &tools, snapshot_tool.as_ref(), &ctx, task).await;
r.attempts = attempt;
if r.passed {
break;
}
}
results.push(r);
}
results
}
async fn run_task_once<P: LlmProvider>(
provider: &Arc<P>,
tools: &[Arc<dyn Tool>],
snapshot_tool: Option<&Arc<dyn Tool>>,
ctx: &ExecutionContext,
task: &BenchTask,
) -> BenchResult {
let started = Instant::now();
let mut r = BenchResult {
name: task.name.clone(),
difficulty: task.difficulty.clone(),
..BenchResult::default()
};
let mut answer = String::new();
let trace = Arc::new(Mutex::new(RunTrace::default()));
let trace_cb = Arc::clone(&trace);
let on_event: Arc<crate::agent::events::OnEvent> = Arc::new(move |ev: AgentEvent| {
let Ok(mut t) = trace_cb.lock() else { return };
match ev {
AgentEvent::TurnStarted { turn, .. } => t.turns = t.turns.max(turn),
AgentEvent::ToolCallStarted { tool_name, .. } => {
t.tool_calls += 1;
t.tools.push(tool_name);
}
AgentEvent::RunCompleted { total_usage, .. } => {
t.input_tokens = total_usage.input_tokens;
t.output_tokens = total_usage.output_tokens;
}
_ => {}
}
});
let prune = crate::agent::pruner::SessionPruneConfig {
keep_recent_n: 3,
pruned_tool_result_max_bytes: 256,
preserve_task: true,
};
match BrowserAgentBuilder::new(Arc::clone(provider))
.name(task.name.clone())
.allow_hosts(task.allow_hosts.clone())
.max_turns(task.max_turns)
.tools_allow(task.tools.clone())
.on_event(on_event)
.session_prune(prune)
.max_identical_tool_calls(3)
.build_with_tools(tools.to_vec())
{
Err(e) => r.error = Some(format!("build: {e}")),
Ok(agent) => match agent.execute(&task.instruction).await {
Err(e) => r.error = Some(format!("run: {e}")),
Ok(out) => {
r.cost_usd = out.estimated_cost_usd;
r.answer_excerpt = out.result.chars().take(160).collect();
answer = out.result;
}
},
}
let build_failed = matches!(&r.error, Some(e) if e.starts_with("build:"));
if !build_failed {
let snap = match snapshot_tool {
Some(t) => t
.execute(ctx, serde_json::json!({}))
.await
.map(|o| o.content)
.unwrap_or_default(),
None => String::new(),
};
r.passed = task.oracle.grade(&snap, &answer);
r.final_snapshot = snap;
}
r.answer = answer;
if let Ok(t) = trace.lock() {
r.turns = t.turns;
r.tool_calls = t.tool_calls;
r.trace = t.tools.clone();
r.input_tokens = t.input_tokens;
r.output_tokens = t.output_tokens;
}
r.millis = started.elapsed().as_millis();
if let Some((pin, pout)) = provider.model_name().and_then(model_price_per_mtok) {
r.cost_usd = Some(estimate_cost_usd(
r.input_tokens,
r.output_tokens,
pin,
pout,
));
}
r
}
pub fn estimate_cost_usd(
input_tokens: u32,
output_tokens: u32,
in_per_mtok: f64,
out_per_mtok: f64,
) -> f64 {
(input_tokens as f64 / 1_000_000.0) * in_per_mtok
+ (output_tokens as f64 / 1_000_000.0) * out_per_mtok
}
pub fn model_price_per_mtok(model: &str) -> Option<(f64, f64)> {
match model {
"deepseek/deepseek-v3.2" => Some((0.2520, 0.3780)),
"moonshotai/kimi-k2-0905" => Some((0.6000, 2.5000)),
"z-ai/glm-4.6" => Some((0.4286, 1.7143)),
"qwen/qwen3-235b-a22b-2507" => Some((0.0710, 0.1000)),
"qwen/qwen3-235b-a22b-thinking-2507" => Some((0.0780, 0.3600)),
"minimax/minimax-m2" => Some((0.2600, 1.0000)),
"google/gemini-2.5-pro" => Some((1.2500, 10.0000)),
"google/gemini-3.1-pro-preview" => Some((2.0000, 12.0000)),
"x-ai/grok-4.3" => Some((1.2500, 2.5000)),
"x-ai/grok-4.20" => Some((1.2500, 2.5000)),
"openai/gpt-5.1" => Some((1.2500, 10.0000)),
"openai/gpt-4.1" => Some((2.0000, 8.0000)),
"anthropic/claude-opus-4.8" => Some((5.0000, 25.0000)),
_ => None,
}
}
pub fn scorecard(results: &[BenchResult]) -> String {
use std::fmt::Write as _;
let passed = results.iter().filter(|r| r.passed).count();
let total_cost: f64 = results.iter().filter_map(|r| r.cost_usd).sum();
let mut s = String::new();
let _ = writeln!(
s,
"=== Browser-agent benchmark: {}/{} tasks passed (est. ${:.5} total) ===",
passed,
results.len(),
total_cost
);
let _ = writeln!(
s,
"{:<24} {:<8} {:<5} {:>4} {:>6} {:>8} {:>8} {:>10} {:>9}",
"task", "diff", "pass", "att", "calls", "in_tok", "out_tok", "cost$", "ms"
);
for r in results {
let cost = match r.cost_usd {
Some(c) => format!("{c:.5}"),
None => "-".to_string(),
};
let _ = writeln!(
s,
"{:<24} {:<8} {:<5} {:>4} {:>6} {:>8} {:>8} {:>10} {:>9}",
r.name,
r.difficulty,
if r.passed { "PASS" } else { "FAIL" },
r.attempts,
r.tool_calls,
r.input_tokens,
r.output_tokens,
cost,
r.millis
);
if let Some(e) = &r.error {
let _ = writeln!(s, " ! {e}");
}
if !r.trace.is_empty() {
let _ = writeln!(s, " trace: {}", r.trace.join(" -> "));
}
if !r.answer_excerpt.is_empty() {
let _ = writeln!(s, " answer: {}", r.answer_excerpt.replace('\n', " "));
}
}
s
}
pub fn bench_suite() -> Vec<BenchTask> {
vec![
BenchTask {
name: "example_extract".into(),
difficulty: "easy".into(),
allow_hosts: vec!["example.com".into()],
instruction: "Go to https://example.com and report the main heading text shown on \
the page."
.into(),
oracle: Oracle::FinalPageContains("Example Domain".into()),
max_turns: 8,
tools: vec!["navigate_page".into(), "take_snapshot".into()],
},
BenchTask {
name: "the_internet_login".into(),
difficulty: "medium".into(),
allow_hosts: vec!["the-internet.herokuapp.com".into()],
instruction: "Go to https://the-internet.herokuapp.com/login and log in with \
username \"tomsmith\" and password \"SuperSecretPassword!\". Confirm \
you reached the secure area."
.into(),
oracle: Oracle::FinalPageContains("You logged into a secure area!".into()),
max_turns: 22,
tools: vec![
"navigate_page".into(),
"take_snapshot".into(),
"fill".into(),
"fill_form".into(),
"click".into(),
],
},
BenchTask {
name: "dynamic_loading_settle".into(),
difficulty: "hard".into(),
allow_hosts: vec!["the-internet.herokuapp.com".into()],
instruction: "Go to https://the-internet.herokuapp.com/dynamic_loading/2 , click the \
Start button, wait for the content to finish loading, and report the \
text that appears."
.into(),
oracle: Oracle::FinalPageContains("Hello World!".into()),
max_turns: 18,
tools: vec![
"navigate_page".into(),
"take_snapshot".into(),
"click".into(),
"wait_for".into(),
],
},
BenchTask {
name: "books_travel_price".into(),
difficulty: "hardest".into(),
allow_hosts: vec!["books.toscrape.com".into()],
instruction: "Go to https://books.toscrape.com , open the Travel category, find the \
book titled \"Under the Tuscan Sun\", open its page, and report its \
exact price."
.into(),
oracle: Oracle::AgentAnswerContains("37.33".into()),
max_turns: 24,
tools: vec![
"navigate_page".into(),
"take_snapshot".into(),
"click".into(),
],
},
]
}
pub fn hard_suite() -> Vec<BenchTask> {
let books = || {
vec![
"navigate_page".to_string(),
"take_snapshot".to_string(),
"click".to_string(),
]
};
vec![
BenchTask {
name: "books_mystery_count".into(),
difficulty: "hard".into(),
allow_hosts: vec!["books.toscrape.com".into()],
instruction: "Go to https://books.toscrape.com , open the Mystery category, and \
report exactly how many books are in it (the number of results)."
.into(),
oracle: Oracle::AgentAnswerContains("32".into()),
max_turns: 20,
tools: books(),
},
BenchTask {
name: "books_travel_priciest".into(),
difficulty: "hardest".into(),
allow_hosts: vec!["books.toscrape.com".into()],
instruction: "Go to https://books.toscrape.com , open the Travel category, compare \
the prices of every book in it, and report the TITLE of the most \
expensive one."
.into(),
oracle: Oracle::AgentAnswerContains("The Great Railway Bazaar".into()),
max_turns: 26,
tools: books(),
},
BenchTask {
name: "books_catalog_total".into(),
difficulty: "hard".into(),
allow_hosts: vec!["books.toscrape.com".into()],
instruction: "Go to https://books.toscrape.com and report the total number of books \
listed in the whole catalogue."
.into(),
oracle: Oracle::AgentAnswerContains("1000".into()),
max_turns: 16,
tools: books(),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::llm::types::{
CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
};
const LOGIN_OK_SNAP: &str = r#"uid=1_0 RootWebArea "The Internet" url="https://the-internet.herokuapp.com/secure"
uid=1_1 StaticText "You logged into a secure area!"
uid=1_2 button "Logout""#;
const LOGIN_FAIL_SNAP: &str = r#"uid=1_0 RootWebArea "The Internet" url="https://the-internet.herokuapp.com/login"
uid=1_1 StaticText "Your username is invalid!"
uid=1_2 textbox "Username""#;
#[test]
fn final_page_contains_grades_on_banner() {
let o = Oracle::FinalPageContains("You logged into a secure area!".into());
assert!(o.grade(LOGIN_OK_SNAP, "I logged in."));
assert!(!o.grade(LOGIN_FAIL_SNAP, "I logged in.")); }
#[test]
fn url_contains_grades_on_redirect() {
let o = Oracle::UrlContains("/secure".into());
assert!(o.grade(LOGIN_OK_SNAP, ""));
assert!(!o.grade(LOGIN_FAIL_SNAP, ""));
}
#[test]
fn agent_answer_contains_is_case_insensitive() {
let o = Oracle::AgentAnswerContains("£51.77".into());
assert!(o.grade("", "The price is £51.77 incl. tax."));
assert!(!o.grade("", "I could not find the price."));
let o2 = Oracle::AgentAnswerContains("Hello World".into());
assert!(o2.grade("", "the revealed text was HELLO WORLD!"));
}
#[test]
fn oracle_does_not_trust_agent_over_page() {
let o = Oracle::FinalPageContains("secure area".into());
assert!(
!o.grade(LOGIN_FAIL_SNAP, "Done! Successfully logged in."),
"page is the source of truth, not the agent's claim"
);
}
#[test]
fn snapshot_url_extracts_root_url() {
assert_eq!(
snapshot_url(LOGIN_OK_SNAP),
Some("https://the-internet.herokuapp.com/secure")
);
assert_eq!(snapshot_url("uid=1_0 RootWebArea \"x\""), None); assert_eq!(snapshot_url(""), None);
}
#[test]
fn bench_suite_is_wellformed() {
let suite = bench_suite();
assert_eq!(suite.len(), 4, "tiered suite has 4 tasks");
for t in &suite {
assert!(!t.name.is_empty());
assert!(
!t.allow_hosts.is_empty(),
"{} must allowlist a host",
t.name
);
assert!(
t.instruction.contains("http"),
"{} instruction must name a URL",
t.name
);
assert!(t.max_turns >= 4, "{} max_turns too low", t.name);
assert!(
t.allow_hosts
.iter()
.any(|h| t.instruction.contains(h.as_str())),
"{} instruction must target its allowlisted host",
t.name
);
}
let diffs: Vec<_> = suite.iter().map(|t| t.difficulty.as_str()).collect();
assert!(
diffs.contains(&"easy") && diffs.contains(&"hardest"),
"suite must span easy..hardest, got {diffs:?}"
);
}
async fn live_setup() -> (String, Vec<Arc<dyn Tool>>, Vec<BenchTask>, usize) {
let key = std::env::var("LLM_API_KEY")
.or_else(|_| std::env::var("OPENROUTER_API_KEY"))
.expect("set LLM_API_KEY or OPENROUTER_API_KEY to run this live benchmark");
let chrome = "/usr/bin/google-chrome";
let extra: Vec<String> = if std::path::Path::new(chrome).exists() {
vec!["--executable-path".to_string(), chrome.to_string()]
} else {
Vec::new()
};
let tools = crate::connect_preset_with_args("chrome-devtools", &extra)
.await
.expect("connect chrome-devtools preset");
let mut suite = bench_suite();
if let Ok(only) = std::env::var("BENCH_ONLY") {
suite.retain(|t| t.name.contains(&only));
assert!(!suite.is_empty(), "BENCH_ONLY={only} matched no task");
}
let attempts = std::env::var("BENCH_ATTEMPTS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(3);
(key, tools, suite, attempts)
}
#[tokio::test]
#[ignore = "live: needs OpenRouter key + spawns real Chrome; hits 3 external sites"]
async fn live_kimi_browser_benchmark() {
let (key, tools, suite, attempts) = live_setup().await;
let model = std::env::var("BENCH_MODEL")
.unwrap_or_else(|_| "qwen/qwen3-235b-a22b-2507".to_string());
let provider = Arc::new(crate::OpenRouterProvider::new(key, model));
let results = run_bench_with_retries(provider, tools, &suite, attempts).await;
eprintln!("\n{}", scorecard(&results));
if let Some(easy) = results.iter().find(|r| r.name == "example_extract") {
assert!(
easy.passed,
"harness sanity: the easy extract task must pass (see scorecard above)"
);
}
}
#[tokio::test]
#[ignore = "live: needs OpenRouter key + spawns real Chrome; runs the suite per model"]
async fn live_model_matrix_benchmark() {
let (key, tools, suite, attempts) = live_setup().await;
let models: Vec<String> = std::env::var("BENCH_MODELS")
.unwrap_or_else(|_| {
"deepseek/deepseek-v3.2,moonshotai/kimi-k2-0905,z-ai/glm-4.6,\
qwen/qwen3-235b-a22b-2507,minimax/minimax-m2,\
google/gemini-2.5-pro,google/gemini-3.1-pro-preview,\
x-ai/grok-4.3,x-ai/grok-4.20,\
openai/gpt-5.1,openai/gpt-4.1"
.to_string()
})
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let mut summary =
String::from("\n=== MODEL MATRIX (same tasks, same harness, pass@k) ===\n");
for model in &models {
let provider = Arc::new(crate::OpenRouterProvider::new(key.clone(), model.clone()));
let results =
run_bench_with_retries(Arc::clone(&provider), tools.clone(), &suite, attempts)
.await;
let passed = results.iter().filter(|r| r.passed).count();
let in_tok: u32 = results.iter().map(|r| r.input_tokens).sum();
let out_tok: u32 = results.iter().map(|r| r.output_tokens).sum();
let per_task: Vec<String> = results
.iter()
.map(|r| {
format!(
"{}:{}",
r.name.split('_').next().unwrap_or(&r.name),
if r.passed { "P" } else { "F" }
)
})
.collect();
eprintln!("\n--- {model} ---\n{}", scorecard(&results));
summary.push_str(&format!(
"{:<34} {}/{} in={:>7} out={:>6} [{}]\n",
model,
passed,
results.len(),
in_tok,
out_tok,
per_task.join(" ")
));
}
eprintln!("{summary}");
}
async fn opus_judge<P: crate::llm::LlmProvider>(
judge: &P,
task: &str,
snapshot: &str,
answer: &str,
) -> (bool, String, u32, u32) {
use crate::browser::judge::{
CompletionVerdict, build_completion_prompt, parse_completion_verdict,
};
use crate::llm::types::{CompletionRequest, ContentBlock, Message};
let prompt = build_completion_prompt(task, &[], &[], snapshot);
let req = CompletionRequest {
system: String::new(),
messages: vec![Message::user(format!(
"{prompt}\n\n# The agent's reported answer\n{answer}"
))],
tools: Vec::new(),
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
match judge.complete(req).await {
Err(e) => (false, format!("judge error: {e}"), 0, 0),
Ok(resp) => {
let text: String = resp
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
let (pass, reason) = match parse_completion_verdict(&text) {
Some(CompletionVerdict::Complete) => (true, "COMPLETE".to_string()),
Some(CompletionVerdict::Incomplete(r)) => (false, format!("INCOMPLETE: {r}")),
Some(CompletionVerdict::Uncertain(r)) => (false, format!("UNCERTAIN: {r}")),
None => (false, "unparseable judge reply".to_string()),
};
(
pass,
reason,
resp.usage.input_tokens,
resp.usage.output_tokens,
)
}
}
}
#[tokio::test]
#[ignore = "live: needs OpenRouter key + Chrome; runs hard suite + Opus judge"]
async fn live_qwen_hard_opus_judge() {
let (key, tools, _suite, attempts) = live_setup().await;
let agent_model = std::env::var("BENCH_MODEL")
.unwrap_or_else(|_| "qwen/qwen3-235b-a22b-2507".to_string());
let judge_model = std::env::var("JUDGE_MODEL")
.unwrap_or_else(|_| "anthropic/claude-opus-4.8".to_string());
let provider = Arc::new(crate::OpenRouterProvider::new(
key.clone(),
agent_model.clone(),
));
let judge = crate::OpenRouterProvider::new(key, judge_model.clone());
let suite = hard_suite();
let results = run_bench_with_retries(provider, tools, &suite, attempts).await;
eprintln!("\n=== HARD SUITE: {agent_model} (agent) judged by {judge_model} ===");
eprintln!("{}", scorecard(&results));
let (jp_in, jp_out) = model_price_per_mtok(&judge_model).unwrap_or((0.0, 0.0));
let mut judge_in = 0u32;
let mut judge_out = 0u32;
let mut both_pass = 0usize;
for r in &results {
let task = suite.iter().find(|t| t.name == r.name);
let instr = task.map(|t| t.instruction.as_str()).unwrap_or("");
let (jpass, reason, jin, jout) =
opus_judge(&judge, instr, &r.final_snapshot, &r.answer).await;
judge_in += jin;
judge_out += jout;
if r.passed && jpass {
both_pass += 1;
}
eprintln!(
"{:<24} oracle={} opus={} | {}",
r.name,
if r.passed { "PASS" } else { "FAIL" },
if jpass { "PASS" } else { "FAIL" },
reason.chars().take(120).collect::<String>()
);
}
let judge_cost = estimate_cost_usd(judge_in, judge_out, jp_in, jp_out);
eprintln!(
"\noracle+opus agree-PASS: {}/{}. Opus judge tokens in={} out={} (~${:.5}).",
both_pass,
results.len(),
judge_in,
judge_out,
judge_cost
);
}
#[test]
fn hard_suite_is_wellformed() {
let s = hard_suite();
assert!(s.len() >= 3, "hard suite has >=3 tasks");
for t in &s {
assert!(t.instruction.contains("http"), "{} has a URL", t.name);
assert!(!t.tools.is_empty(), "{} pins tools", t.name);
assert!(t.max_turns >= 8, "{} has headroom", t.name);
}
let joined: String = s
.iter()
.map(|t| match &t.oracle {
Oracle::AgentAnswerContains(v) => v.clone(),
_ => String::new(),
})
.collect::<Vec<_>>()
.join("|");
assert!(
joined.contains("32")
&& joined.contains("Great Railway Bazaar")
&& joined.contains("1000")
);
}
#[test]
fn scorecard_counts_and_formats() {
let results = vec![
BenchResult {
name: "login".into(),
difficulty: "hard".into(),
passed: true,
tool_calls: 5,
input_tokens: 1200,
output_tokens: 80,
cost_usd: Some(0.01),
millis: 4200,
turns: 4,
attempts: 1,
trace: vec![
"navigate_page".into(),
"take_snapshot".into(),
"fill_form".into(),
"click".into(),
],
answer_excerpt: "logged in".into(),
error: None,
..Default::default()
},
BenchResult {
name: "basket".into(),
difficulty: "hardest".into(),
passed: false,
tool_calls: 9,
input_tokens: 3000,
output_tokens: 140,
cost_usd: None,
millis: 9000,
turns: 14,
attempts: 3,
trace: vec!["navigate_page".into(), "take_snapshot".into()],
answer_excerpt: String::new(),
error: Some("run: timeout".into()),
..Default::default()
},
];
let card = scorecard(&results);
assert!(card.contains("1/2 tasks passed"));
assert!(card.contains("login"));
assert!(card.contains("basket"));
assert!(card.contains("PASS"));
assert!(card.contains("FAIL"));
assert!(card.contains("! run: timeout"));
assert!(card.contains("trace: navigate_page -> take_snapshot"));
assert!(card.contains("cost$"), "scorecard has a cost column");
assert!(card.contains("0.01000"), "priced row shows its cost");
assert!(
card.contains("est. $0.01000 total"),
"header shows total cost"
);
}
#[test]
fn estimate_cost_is_tokens_times_price() {
let c = estimate_cost_usd(1_000_000, 1_000_000, 0.07, 0.10);
assert!((c - 0.17).abs() < 1e-9, "got {c}");
let q = estimate_cost_usd(73477, 429, 0.071, 0.10);
assert!((0.005..0.006).contains(&q), "qwen run ~ $0.0053, got {q}");
assert_eq!(estimate_cost_usd(0, 0, 1.0, 1.0), 0.0);
}
#[test]
fn price_table_known_and_unknown() {
assert_eq!(
model_price_per_mtok("qwen/qwen3-235b-a22b-2507"),
Some((0.0710, 0.1000))
);
assert_eq!(
model_price_per_mtok("anthropic/claude-opus-4.8"),
Some((5.0, 25.0))
);
assert_eq!(model_price_per_mtok("nonexistent/model"), None);
}
struct PricedMock;
impl LlmProvider for PricedMock {
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: "the answer is 42".to_string(),
}],
stop_reason: StopReason::EndTurn,
reasoning: None,
usage: TokenUsage {
input_tokens: 1000,
output_tokens: 100,
..Default::default()
},
model: None,
})
}
fn model_name(&self) -> Option<&str> {
Some("qwen/qwen3-235b-a22b-2507")
}
}
#[tokio::test]
async fn run_task_once_derives_cost_from_price_table() {
let provider = Arc::new(PricedMock);
let tools: Vec<Arc<dyn Tool>> = Vec::new();
let ctx = ExecutionContext::default();
let task = BenchTask {
name: "cost".to_string(),
difficulty: "easy".to_string(),
allow_hosts: vec![],
instruction: "say the answer".to_string(),
oracle: Oracle::AgentAnswerContains("42".to_string()),
max_turns: 1,
tools: vec![],
};
let r = run_task_once(&provider, &tools, None, &ctx, &task).await;
assert!(
r.input_tokens > 0,
"token counts must be captured from the run (got {})",
r.input_tokens
);
let (pin, pout) = model_price_per_mtok("qwen/qwen3-235b-a22b-2507").unwrap();
let expected = estimate_cost_usd(r.input_tokens, r.output_tokens, pin, pout);
assert_eq!(
r.cost_usd,
Some(expected),
"cost must derive from the price table using the real token counts"
);
assert_ne!(r.cost_usd, Some(0.0), "cost must be nonzero for a real run");
}
}