1#![allow(
13 clippy::expect_used,
14 clippy::unwrap_used,
15 clippy::panic,
16 clippy::missing_panics_doc
17)]
18
19pub mod a2a;
21pub mod budgets;
23pub mod costs;
25pub mod diagnostics;
27pub mod endpoints;
29pub mod mcp_client;
31pub mod mcp_server;
33pub mod reporting;
35pub mod runner;
37pub mod scenario;
39pub mod selectors;
41
42#[cfg(feature = "a2a-server")]
44pub mod a2a_server;
45
46#[cfg(feature = "macros")]
48pub mod macros;
49
50use std::collections::HashMap;
51use std::time::Duration;
52
53use serde_json::Value;
54
55pub use costs::LlmResponse;
56pub use costs::LlmUsage;
57
58#[derive(Debug, Clone)]
61pub struct LlmConfig {
62 pub url: String,
64 pub model: String,
66 pub api_key: Option<String>,
68 pub headers: HashMap<String, String>,
70 pub timeout: Duration,
72 pub temperature: f64,
74 pub thinking: Option<bool>,
77 pub model_params: HashMap<String, Value>,
80}
81
82impl LlmConfig {
83 #[must_use]
86 pub fn from_env() -> Self {
87 Self {
88 url: llm_base_url(),
89 model: llm_model(),
90 api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
91 headers: parse_headers_env(),
92 timeout: Duration::from_secs(60),
93 temperature: 0.0,
94 thinking: None,
95 model_params: HashMap::new(),
96 }
97 }
98}
99
100#[must_use]
104pub fn parse_headers_env() -> HashMap<String, String> {
105 let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
106 return HashMap::new();
107 };
108 let Ok(json) = serde_json::from_str::<Value>(&raw) else {
109 return HashMap::new();
110 };
111 let Some(obj) = json.as_object() else {
112 return HashMap::new();
113 };
114 obj.iter()
115 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
116 .collect()
117}
118
119#[must_use]
122pub fn base_url() -> String {
123 std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
124}
125
126#[must_use]
129pub fn llm_base_url() -> String {
130 std::env::var("HARNESS_LLM_TEST_URL")
131 .unwrap_or_else(|_| "http://localhost:8080".to_owned())
132 .trim_end_matches('/')
133 .to_owned()
134}
135
136#[must_use]
139pub fn llm_model() -> String {
140 std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
141}
142
143#[must_use]
146pub fn browser_headless() -> bool {
147 std::env::var("HARNESS_BROWSER_HEADLESS")
148 .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
149}
150
151#[must_use]
153pub fn http_client(timeout: Duration) -> reqwest::Client {
154 reqwest::Client::builder()
155 .timeout(timeout)
156 .build()
157 .expect("build reqwest client")
158}
159
160#[must_use]
166pub async fn llm_chat(llm: &LlmConfig, system: &str, user: &str) -> Option<String> {
167 llm_chat_with_usage(llm, system, user)
168 .await
169 .map(|r| r.content)
170 .ok()
171}
172
173pub async fn llm_chat_with_usage(
190 llm: &LlmConfig,
191 system: &str,
192 user: &str,
193) -> Result<LlmResponse, String> {
194 let client = http_client(llm.timeout);
195 let mut last_err = String::from("LLM call failed");
196 let mut attempts: u32 = 0;
197
198 while attempts < LLM_CALL_ATTEMPTS {
199 attempts += 1;
200 match llm_chat_once(&client, llm, system, user).await {
201 Ok(resp) => return Ok(resp),
202 Err(err) => {
203 last_err = err.to_string();
204 if attempts >= LLM_CALL_ATTEMPTS || !err.is_retryable() {
205 break;
206 }
207 tokio::time::sleep(Duration::from_millis(500 * u64::from(attempts))).await;
208 }
209 }
210 }
211
212 Err(format!(
213 "LLM call failed after {attempts} attempt(s) (endpoint {url}): {last_err}",
214 url = llm.url
215 ))
216}
217
218const LLM_CALL_ATTEMPTS: u32 = 3;
220
221enum LlmCallError {
225 Transport { message: String },
227 Http { status: u16, body: String },
229 InvalidJson {
231 status: u16,
232 detail: String,
233 body: String,
234 },
235 MissingContent { json: String },
237}
238
239impl std::fmt::Display for LlmCallError {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 match self {
242 Self::Transport { message } => write!(f, "LLM HTTP request failed: {message}"),
243 Self::Http { status, body } => {
244 write!(
245 f,
246 "LLM endpoint returned HTTP {status}: {}",
247 truncate(body, 300)
248 )
249 }
250 Self::InvalidJson {
251 status,
252 detail,
253 body,
254 } => write!(
255 f,
256 "LLM endpoint returned HTTP {status} with non-JSON body ({detail}): {}",
257 truncate(body, 300)
258 ),
259 Self::MissingContent { json } => write!(
260 f,
261 "LLM response missing choices[0].message.content: {}",
262 truncate(json, 300)
263 ),
264 }
265 }
266}
267
268impl LlmCallError {
269 #[must_use]
273 fn is_retryable(&self) -> bool {
274 match self {
275 Self::Transport { .. } | Self::MissingContent { .. } => true,
276 Self::Http { status, .. } => {
277 *status == 408 || *status == 429 || (500..600).contains(status)
278 }
279 Self::InvalidJson { status, .. } => {
280 *status == 200 || *status == 408 || *status == 429 || (500..600).contains(status)
281 }
282 }
283 }
284}
285
286async fn llm_chat_once(
288 client: &reqwest::Client,
289 llm: &LlmConfig,
290 system: &str,
291 user: &str,
292) -> Result<LlmResponse, LlmCallError> {
293 let mut payload = serde_json::json!({
294 "model": llm.model,
295 "messages": [
296 {"role": "system", "content": system},
297 {"role": "user", "content": user}
298 ],
299 "max_tokens": 4096,
300 "temperature": llm.temperature
301 });
302 if let Some(think) = llm.thinking {
303 if think {
304 payload["thinking"] = serde_json::json!({"type": "enabled"});
305 } else {
306 payload["thinking"] = serde_json::json!({"type": "disabled"});
307 }
308 }
309 if !llm.model_params.is_empty() {
311 if let Value::Object(ref mut map) = payload {
312 for (key, val) in &llm.model_params {
313 map.insert(key.clone(), val.clone());
314 }
315 }
316 }
317
318 let mut req = client
319 .post(format!("{}/v1/chat/completions", llm.url))
320 .header("Content-Type", "application/json");
321
322 if let Some(ref key) = llm.api_key {
323 req = req.header("Authorization", format!("Bearer {key}"));
324 }
325 for (name, value) in &llm.headers {
326 req = req.header(name.as_str(), value.as_str());
327 }
328
329 let resp = req
330 .json(&payload)
331 .send()
332 .await
333 .map_err(|e| LlmCallError::Transport {
334 message: e.to_string(),
335 })?;
336 let status = resp.status();
337 let status_u16 = status.as_u16();
338 let body = resp.text().await.unwrap_or_default();
339 if !status.is_success() {
340 return Err(LlmCallError::Http {
341 status: status_u16,
342 body,
343 });
344 }
345 let json: Value = match serde_json::from_str(&body) {
346 Ok(v) => v,
347 Err(e) => {
348 return Err(LlmCallError::InvalidJson {
349 status: status_u16,
350 detail: e.to_string(),
351 body,
352 });
353 }
354 };
355 let usage = costs::extract_usage(&json);
356 let content = json["choices"][0]["message"]["content"]
357 .as_str()
358 .map(String::from)
359 .ok_or_else(|| LlmCallError::MissingContent {
360 json: json.to_string(),
361 })?;
362
363 Ok(LlmResponse { content, usage })
364}
365
366pub const DOM_EXTRACT_JS: &str = r#"
369(() => {
370 const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
371 const els = document.querySelectorAll(interactive);
372 const info = [];
373 const seen = new Set();
374 els.forEach((el, i) => {
375 const rect = el.getBoundingClientRect();
376 if (rect.width === 0 || rect.height === 0) return;
377 const tag = el.tagName.toLowerCase();
378 let selector = '';
379 if (el.id) selector = '#' + CSS.escape(el.id);
380 else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
381 else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
382 else if (el.className && typeof el.className === 'string') {
383 const cls = el.className.trim().split(/\\s+/)[0];
384 if (cls) selector = tag + '.' + CSS.escape(cls);
385 }
386 if (!selector) selector = tag;
387 if (seen.has(selector)) return;
388 seen.add(selector);
389
390 let label = '';
391 const aria = el.getAttribute('aria-label');
392 if (aria) {
393 label = aria;
394 } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
395 label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
396 if (el.type && !label) label = el.type;
397 } else {
398 label = (el.textContent || '').trim().substring(0, 80);
399 }
400
401 info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
402 });
403 return JSON.stringify(info);
404})()
405"#;
406
407#[must_use]
410pub fn truncate(s: &str, max_len: usize) -> String {
411 if s.len() <= max_len {
412 s.to_owned()
413 } else {
414 format!("{}...<truncated>", &s[..max_len])
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use crate::costs::extract_usage;
421 use crate::truncate;
422 use crate::{llm_base_url, llm_model, parse_headers_env, LlmConfig};
423
424 #[test]
425 fn test_truncate_short() {
426 assert_eq!(truncate("hello", 10), "hello");
427 }
428
429 #[test]
430 fn test_truncate_long() {
431 let result = truncate("hello world", 5);
432 assert!(result.contains("<truncated>"));
433 assert!(result.starts_with("hello"));
434 }
435
436 #[test]
437 fn test_truncate_exact_length() {
438 assert_eq!(truncate("abcde", 5), "abcde");
439 }
440
441 #[test]
442 fn test_truncate_empty() {
443 assert_eq!(truncate("", 5), "");
444 }
445
446 #[test]
447 fn test_parse_headers_env_empty() {
448 std::env::remove_var("HARNESS_LLM_HEADERS");
449 let h = parse_headers_env();
450 assert!(h.is_empty());
451 }
452
453 #[test]
454 fn test_parse_headers_env_valid() {
455 std::env::set_var("HARNESS_LLM_HEADERS", r#"{"X-Org":"acme","X-Version":"1"}"#);
456 let h = parse_headers_env();
457 assert_eq!(h.get("X-Org").map(String::as_str), Some("acme"));
458 assert_eq!(h.get("X-Version").map(String::as_str), Some("1"));
459 std::env::remove_var("HARNESS_LLM_HEADERS");
460 }
461
462 #[test]
463 fn test_parse_headers_env_invalid_json() {
464 std::env::set_var("HARNESS_LLM_HEADERS", "not-json");
465 let h = parse_headers_env();
466 assert!(h.is_empty());
467 std::env::remove_var("HARNESS_LLM_HEADERS");
468 }
469
470 #[test]
471 fn test_llm_config_from_env_defaults() {
472 #[allow(clippy::float_cmp)]
473 {
474 let config = LlmConfig::from_env();
475 assert_eq!(config.temperature, 0.0);
476 assert!(config.thinking.is_none());
477 assert!(config.model_params.is_empty());
478 }
479 }
480
481 #[test]
482 fn test_extract_usage_full() {
483 let json = serde_json::json!({
484 "usage": {
485 "prompt_tokens": 100,
486 "completion_tokens": 200,
487 "total_tokens": 300
488 }
489 });
490 let usage = extract_usage(&json);
491 assert_eq!(usage.prompt_tokens, 100);
492 assert_eq!(usage.completion_tokens, 200);
493 assert_eq!(usage.total_tokens, 300);
494 }
495
496 #[test]
497 fn test_extract_usage_empty() {
498 let json = serde_json::json!({});
499 let usage = extract_usage(&json);
500 assert_eq!(usage.prompt_tokens, 0);
501 assert_eq!(usage.completion_tokens, 0);
502 assert_eq!(usage.total_tokens, 0);
503 }
504
505 #[test]
506 fn test_truncate_unicode() {
507 assert_eq!(truncate("héllo", 3), "hé...<truncated>");
510 assert_eq!(truncate("hello", 5), "hello");
512 }
513
514 #[test]
515 fn test_parse_headers_env_non_object() {
516 std::env::set_var("HARNESS_LLM_HEADERS", "[1, 2, 3]");
517 let h = parse_headers_env();
518 assert!(h.is_empty());
519 std::env::remove_var("HARNESS_LLM_HEADERS");
520 }
521
522 #[test]
523 fn test_parse_headers_env_nested_values_filtered() {
524 std::env::set_var(
525 "HARNESS_LLM_HEADERS",
526 r#"{"str":"val","num":42,"bool":true}"#,
527 );
528 let h = parse_headers_env();
529 assert_eq!(h.get("str").map(String::as_str), Some("val"));
530 assert!(!h.contains_key("num"));
531 assert!(!h.contains_key("bool"));
532 std::env::remove_var("HARNESS_LLM_HEADERS");
533 }
534
535 #[test]
536 fn test_llm_config_has_default_model() {
537 let config = LlmConfig::from_env();
538 assert!(!config.model.is_empty());
539 }
540
541 #[test]
542 fn test_llm_base_url_default() {
543 std::env::remove_var("HARNESS_LLM_TEST_URL");
544 let url = llm_base_url();
545 assert_eq!(url, "http://localhost:8080");
546 }
547
548 #[test]
549 fn test_llm_base_url_custom() {
550 std::env::set_var("HARNESS_LLM_TEST_URL", "https://custom.api.com/v1");
551 let url = llm_base_url();
552 assert_eq!(url, "https://custom.api.com/v1");
553 std::env::remove_var("HARNESS_LLM_TEST_URL");
554 }
555
556 #[test]
557 fn test_llm_base_url_trailing_slash() {
558 std::env::set_var("HARNESS_LLM_TEST_URL", "https://api.com/");
559 let url = llm_base_url();
560 assert_eq!(url, "https://api.com");
561 std::env::remove_var("HARNESS_LLM_TEST_URL");
562 }
563
564 #[test]
565 fn test_llm_model_default() {
566 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
567 assert_eq!(llm_model(), "deepseek");
568 }
569
570 #[test]
571 fn test_llm_model_custom() {
572 std::env::set_var("HARNESS_LLM_TEST_MODEL", "gpt-4o");
573 assert_eq!(llm_model(), "gpt-4o");
574 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
575 }
576
577 #[test]
578 fn test_extract_usage_partial() {
579 let json = serde_json::json!({
580 "usage": {
581 "prompt_tokens": 50
582 }
583 });
584 let usage = extract_usage(&json);
585 assert_eq!(usage.prompt_tokens, 50);
586 assert_eq!(usage.completion_tokens, 0);
587 assert_eq!(usage.total_tokens, 0);
588 }
589
590 #[test]
591 fn test_browser_headless_default() {
592 std::env::remove_var("HARNESS_BROWSER_HEADLESS");
593 assert!(crate::browser_headless());
594 }
595}