1#![allow(
13 clippy::expect_used,
14 clippy::unwrap_used,
15 clippy::panic,
16 clippy::missing_panics_doc
17)]
18
19pub mod a2a;
21mod auth;
23#[cfg(feature = "aws")]
25mod bedrock;
26pub mod budgets;
28pub mod costs;
30pub mod diagnostics;
32pub mod endpoints;
34pub mod events;
36pub mod mcp_client;
38pub mod mcp_server;
40pub mod parallel;
42pub mod pricing;
43pub mod redact;
45pub mod reporting;
47pub mod runner;
49pub mod scenario;
51pub mod selectors;
53pub mod vision;
55
56#[cfg(feature = "a2a-server")]
58pub mod a2a_server;
59
60#[cfg(feature = "macros")]
62pub mod macros;
63
64use std::collections::HashMap;
65use std::time::Duration;
66
67use serde_json::{json, Value};
68
69pub use costs::LlmResponse;
70pub use costs::LlmUsage;
71pub use scenario::AuthConfig;
72pub use scenario::AuthMode;
73pub use scenario::AwsConfig;
74pub use scenario::Provider;
75
76#[derive(Debug, Clone)]
79pub struct LlmConfig {
80 pub url: String,
82 pub model: String,
84 pub api_key: Option<String>,
86 pub headers: HashMap<String, String>,
88 pub timeout: Duration,
90 pub temperature: f64,
92 pub thinking: Option<bool>,
95 pub model_params: HashMap<String, Value>,
98 pub cache: bool,
103 pub max_attempts: u32,
108 pub provider: Provider,
113 pub deployment: Option<String>,
116 pub api_version: Option<String>,
119 pub auth: AuthConfig,
121 pub header_commands: HashMap<String, String>,
124 pub aws: AwsConfig,
126}
127
128impl Default for LlmConfig {
129 fn default() -> Self {
130 Self {
131 url: String::new(),
132 model: String::new(),
133 api_key: None,
134 headers: HashMap::new(),
135 timeout: Duration::from_secs(60),
136 temperature: 0.0,
137 thinking: None,
138 model_params: HashMap::new(),
139 cache: true,
140 max_attempts: default_llm_attempts(),
141 provider: Provider::Openai,
142 deployment: None,
143 api_version: None,
144 auth: AuthConfig::default(),
145 header_commands: HashMap::new(),
146 aws: AwsConfig::default(),
147 }
148 }
149}
150
151impl LlmConfig {
152 #[must_use]
155 pub fn from_env() -> Self {
156 Self {
157 url: llm_base_url(),
158 model: llm_model(),
159 api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
160 headers: parse_headers_env(),
161 timeout: Duration::from_secs(60),
162 temperature: 0.0,
163 thinking: None,
164 model_params: HashMap::new(),
165 cache: true,
166 max_attempts: default_llm_attempts(),
167 provider: Provider::Openai,
168 deployment: None,
169 api_version: None,
170 auth: AuthConfig::default(),
171 header_commands: HashMap::new(),
172 aws: AwsConfig::default(),
173 }
174 }
175}
176
177pub const DEFAULT_AZURE_API_VERSION: &str = "2024-10-21";
180
181#[must_use]
184pub fn build_azure_url(base: &str, deployment: &str, api_version: &str) -> String {
185 let base = base.trim_end_matches('/');
186 let base = base
187 .strip_suffix("/openai")
188 .unwrap_or(base)
189 .trim_end_matches('/');
190 format!("{base}/openai/deployments/{deployment}/chat/completions?api-version={api_version}")
191}
192
193#[must_use]
196pub fn default_llm_attempts() -> u32 {
197 std::env::var("HARNESS_LLM_CALL_ATTEMPTS")
198 .ok()
199 .and_then(|v| v.parse().ok())
200 .filter(|n| *n >= 1)
201 .unwrap_or(3)
202}
203
204#[must_use]
208pub fn parse_headers_env() -> HashMap<String, String> {
209 let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
210 return HashMap::new();
211 };
212 let Ok(json) = serde_json::from_str::<Value>(&raw) else {
213 return HashMap::new();
214 };
215 let Some(obj) = json.as_object() else {
216 return HashMap::new();
217 };
218 obj.iter()
219 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
220 .collect()
221}
222
223#[must_use]
226pub fn base_url() -> String {
227 std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
228}
229
230#[must_use]
233pub fn llm_base_url() -> String {
234 std::env::var("HARNESS_LLM_TEST_URL")
235 .unwrap_or_else(|_| "http://localhost:8080".to_owned())
236 .trim_end_matches('/')
237 .to_owned()
238}
239
240#[must_use]
243pub fn llm_model() -> String {
244 std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
245}
246
247#[must_use]
250pub fn browser_headless() -> bool {
251 std::env::var("HARNESS_BROWSER_HEADLESS")
252 .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
253}
254
255#[must_use]
257pub fn http_client(timeout: Duration) -> reqwest::Client {
258 reqwest::Client::builder()
259 .timeout(timeout)
260 .build()
261 .expect("build reqwest client")
262}
263
264#[must_use]
270pub async fn llm_chat(llm: &LlmConfig, system: &str, user: &str) -> Option<String> {
271 llm_chat_with_usage(llm, system, user)
272 .await
273 .map(|r| r.content)
274 .ok()
275}
276
277pub async fn llm_chat_with_usage(
297 llm: &LlmConfig,
298 system: &str,
299 user: &str,
300) -> Result<LlmResponse, String> {
301 chat_with_retry(llm, system, user, None).await
302}
303
304pub async fn llm_chat_vision_with_usage(
318 llm: &LlmConfig,
319 system: &str,
320 user: &str,
321 image_data_urls: Option<&[String]>,
322) -> Result<LlmResponse, String> {
323 chat_with_retry(llm, system, user, image_data_urls).await
324}
325
326pub async fn llm_chat_with_usage_chain(
338 primary: &LlmConfig,
339 fallbacks: &[LlmConfig],
340 system: &str,
341 user: &str,
342) -> Result<(LlmResponse, usize), String> {
343 chat_chain_with_retry(primary, fallbacks, system, user, None).await
344}
345
346pub async fn llm_chat_vision_with_usage_chain(
352 primary: &LlmConfig,
353 fallbacks: &[LlmConfig],
354 system: &str,
355 user: &str,
356 image_data_urls: Option<&[String]>,
357) -> Result<(LlmResponse, usize), String> {
358 chat_chain_with_retry(primary, fallbacks, system, user, image_data_urls).await
359}
360
361async fn chat_chain_with_retry(
364 primary: &LlmConfig,
365 fallbacks: &[LlmConfig],
366 system: &str,
367 user: &str,
368 image_data_urls: Option<&[String]>,
369) -> Result<(LlmResponse, usize), String> {
370 let mut failures: Vec<String> = Vec::new();
371 for (i, llm) in std::iter::once(primary).chain(fallbacks.iter()).enumerate() {
372 match chat_with_retry(llm, system, user, image_data_urls).await {
373 Ok(resp) => return Ok((resp, i)),
374 Err(e) => failures.push(format!("endpoint '{}' ({:?}): {e}", llm.url, llm.model)),
375 }
376 }
377 let details = failures.iter().fold(String::new(), |mut acc, f| {
378 use std::fmt::Write as _;
379 let _ = writeln!(acc, " - {f}");
380 acc
381 });
382 Err(format!(
383 "LLM call failed on all {} endpoint(s):\n{details}",
384 failures.len()
385 ))
386}
387
388async fn chat_with_retry(
390 llm: &LlmConfig,
391 system: &str,
392 user: &str,
393 image_data_urls: Option<&[String]>,
394) -> Result<LlmResponse, String> {
395 let client = http_client(llm.timeout);
396 let mut last_err = String::from("LLM call failed");
397 let mut attempts: u32 = 0;
398
399 while attempts < llm.max_attempts {
400 attempts += 1;
401 match llm_chat_once(&client, llm, system, user, image_data_urls).await {
402 Ok(resp) => return Ok(resp),
403 Err(err) => {
404 let backoff = match &err {
408 LlmCallError::EmptyBody { .. } => Duration::from_secs(3),
409 _ => Duration::from_millis(500 * u64::from(attempts)),
410 };
411 last_err = err.to_string();
412 if attempts >= llm.max_attempts || !err.is_retryable() {
413 break;
414 }
415 tokio::time::sleep(backoff).await;
416 }
417 }
418 }
419
420 Err(format!(
421 "LLM call failed after {attempts} attempt(s) (endpoint {url}): {last_err}",
422 url = llm.url
423 ))
424}
425
426#[must_use]
431fn build_messages(system: &str, user: &str, image_data_urls: Option<&[String]>) -> Value {
432 let user_content = image_data_urls.map_or_else(
433 || Value::String(user.to_owned()),
434 |urls| {
435 let mut parts = vec![json!({"type": "text", "text": user})];
436 for url in urls {
437 parts.push(json!({"type": "image_url", "image_url": {"url": url}}));
438 }
439 Value::Array(parts)
440 },
441 );
442 json!([
443 {"role": "system", "content": system},
444 {"role": "user", "content": user_content}
445 ])
446}
447
448enum LlmCallError {
452 Transport { message: String },
454 Http { status: u16, body: String },
456 InvalidJson {
458 status: u16,
459 detail: String,
460 body: String,
461 },
462 EmptyBody { status: u16 },
466 MissingContent { json: String },
468 Auth { message: String },
472}
473
474impl std::fmt::Display for LlmCallError {
475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 match self {
477 Self::Transport { message } => write!(f, "LLM HTTP request failed: {message}"),
478 Self::Http { status, body } => {
479 write!(
480 f,
481 "LLM endpoint returned HTTP {status}: {}",
482 truncate(body, 300)
483 )
484 }
485 Self::InvalidJson {
486 status,
487 detail,
488 body,
489 } => write!(
490 f,
491 "LLM endpoint returned HTTP {status} with non-JSON body ({detail}): {}",
492 truncate(body, 300)
493 ),
494 Self::EmptyBody { status } => write!(
495 f,
496 "LLM endpoint returned HTTP {status} with an empty response (likely gateway warm-up)"
497 ),
498 Self::MissingContent { json } => write!(
499 f,
500 "LLM response missing choices[0].message.content: {}",
501 truncate(json, 300)
502 ),
503 Self::Auth { message } => write!(f, "LLM authentication failed: {message}"),
504 }
505 }
506}
507
508impl LlmCallError {
509 #[must_use]
513 fn is_retryable(&self) -> bool {
514 match self {
515 Self::Transport { .. }
516 | Self::MissingContent { .. }
517 | Self::EmptyBody { .. }
518 | Self::Auth { .. } => true,
519 Self::Http { status, .. } => {
520 *status == 408 || *status == 429 || (500..600).contains(status)
521 }
522 Self::InvalidJson { status, .. } => {
523 *status == 200 || *status == 408 || *status == 429 || (500..600).contains(status)
524 }
525 }
526 }
527}
528
529async fn llm_chat_once(
531 client: &reqwest::Client,
532 llm: &LlmConfig,
533 system: &str,
534 user: &str,
535 image_data_urls: Option<&[String]>,
536) -> Result<LlmResponse, LlmCallError> {
537 match llm.provider {
538 Provider::Openai | Provider::Azure => {
539 chat_openai_compat_once(client, llm, system, user, image_data_urls).await
540 }
541 Provider::Bedrock => {
542 #[cfg(feature = "aws")]
543 let result =
544 crate::bedrock::chat_once(client, llm, system, user, image_data_urls).await;
545 #[cfg(not(feature = "aws"))]
546 let result = Err(LlmCallError::Auth {
547 message:
548 "provider = \"bedrock\" requires building llm-browser-testkit with the `aws` \
549 cargo feature"
550 .to_owned(),
551 });
552 result
553 }
554 }
555}
556
557async fn chat_openai_compat_once(
561 client: &reqwest::Client,
562 llm: &LlmConfig,
563 system: &str,
564 user: &str,
565 image_data_urls: Option<&[String]>,
566) -> Result<LlmResponse, LlmCallError> {
567 let url = match llm.provider {
568 Provider::Openai => format!("{}/v1/chat/completions", llm.url),
569 Provider::Azure => {
570 let deployment = llm.deployment.clone().unwrap_or_else(|| llm.model.clone());
571 let api_version = llm
572 .api_version
573 .clone()
574 .unwrap_or_else(|| DEFAULT_AZURE_API_VERSION.to_owned());
575 build_azure_url(&llm.url, &deployment, &api_version)
576 }
577 Provider::Bedrock => unreachable!("bedrock is dispatched before this function"),
578 };
579
580 let mut headers: Vec<(String, String)> = Vec::new();
581 if llm.auth.mode == AuthMode::ApiKey {
582 match (&llm.auth.api_key_header, &llm.api_key) {
583 (Some(header_name), Some(key)) => {
585 headers.push((header_name.clone(), key.clone()));
586 }
587 (Some(header_name), None) => {
588 return Err(LlmCallError::Auth {
589 message: format!(
590 "auth.api_key_header `{header_name}` requires endpoint api_key to be set"
591 ),
592 });
593 }
594 (None, Some(key)) if llm.provider == Provider::Azure => {
596 headers.push(("api-key".to_owned(), key.clone()));
597 }
598 (None, Some(key)) => {
600 headers.push(("Authorization".to_owned(), format!("Bearer {key}")));
601 }
602 (None, None) => {}
603 }
604 } else if let Some(bearer) = auth::resolve_bearer_token(&llm.auth, llm.api_key.as_deref())
605 .await
606 .map_err(|message| LlmCallError::Auth { message })?
607 {
608 headers.push(("Authorization".to_owned(), format!("Bearer {bearer}")));
609 }
610 for (name, value) in &llm.headers {
611 headers.push((name.clone(), value.clone()));
612 }
613 for (name, command) in &llm.header_commands {
614 let value = auth::run_header_command(command)
615 .await
616 .map_err(|e| LlmCallError::Auth {
617 message: format!("header command for `{name}` failed: {e}"),
618 })?;
619 headers.push((name.clone(), value));
620 }
621
622 let payload = build_openai_payload(llm, system, user, image_data_urls);
623
624 let mut req = client.post(&url).header("Content-Type", "application/json");
625
626 for (name, value) in headers {
627 req = req.header(name.as_str(), value.as_str());
628 }
629
630 let resp = req
631 .json(&payload)
632 .send()
633 .await
634 .map_err(|e| LlmCallError::Transport {
635 message: e.to_string(),
636 })?;
637 let status = resp.status();
638 let status_u16 = status.as_u16();
639 let body = resp.text().await.unwrap_or_default();
640 if !status.is_success() {
641 return Err(LlmCallError::Http {
642 status: status_u16,
643 body,
644 });
645 }
646 if body.trim().is_empty() {
647 return Err(LlmCallError::EmptyBody { status: status_u16 });
650 }
651 let json: Value = match serde_json::from_str(&body) {
652 Ok(v) => v,
653 Err(e) => {
654 return Err(LlmCallError::InvalidJson {
655 status: status_u16,
656 detail: e.to_string(),
657 body,
658 });
659 }
660 };
661 let usage = costs::extract_usage(&json);
662 let content = json["choices"][0]["message"]["content"]
663 .as_str()
664 .map(String::from)
665 .ok_or_else(|| LlmCallError::MissingContent {
666 json: json.to_string(),
667 })?;
668
669 Ok(LlmResponse { content, usage })
670}
671
672#[must_use]
675fn build_openai_payload(
676 llm: &LlmConfig,
677 system: &str,
678 user: &str,
679 image_data_urls: Option<&[String]>,
680) -> Value {
681 let mut payload = serde_json::json!({
682 "model": llm.model,
683 "messages": build_messages(system, user, image_data_urls),
684 "max_tokens": 4096,
685 "temperature": llm.temperature
686 });
687 if let Some(think) = llm.thinking {
688 if think {
689 payload["thinking"] = serde_json::json!({"type": "enabled"});
690 } else {
691 payload["thinking"] = serde_json::json!({"type": "disabled"});
692 }
693 }
694 if !llm.model_params.is_empty() {
696 if let Value::Object(ref mut map) = payload {
697 for (key, val) in &llm.model_params {
698 map.insert(key.clone(), val.clone());
699 }
700 }
701 }
702 if llm.cache && !system.is_empty() && is_anthropic_style_model(&llm.model) {
707 payload["messages"][0]["content"] = serde_json::json!([
708 {"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}
709 ]);
710 }
711 payload
712}
713
714#[must_use]
717fn is_anthropic_style_model(model: &str) -> bool {
718 let model = model.to_ascii_lowercase();
719 model.contains("claude") || model.contains("anthropic")
720}
721
722pub const DOM_EXTRACT_JS: &str = r#"
725(() => {
726 const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
727 const els = document.querySelectorAll(interactive);
728 const info = [];
729 const seen = new Set();
730 els.forEach((el, i) => {
731 const rect = el.getBoundingClientRect();
732 if (rect.width === 0 || rect.height === 0) return;
733 const tag = el.tagName.toLowerCase();
734 let selector = '';
735 if (el.id) selector = '#' + CSS.escape(el.id);
736 else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
737 else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
738 else if (el.className && typeof el.className === 'string') {
739 const cls = el.className.trim().split(/\\s+/)[0];
740 if (cls) selector = tag + '.' + CSS.escape(cls);
741 }
742 if (!selector) selector = tag;
743 if (seen.has(selector)) return;
744 seen.add(selector);
745
746 let label = '';
747 const aria = el.getAttribute('aria-label');
748 if (aria) {
749 label = aria;
750 } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
751 label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
752 if (el.type && !label) label = el.type;
753 } else {
754 label = (el.textContent || '').trim().substring(0, 80);
755 }
756
757 info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
758 });
759 return JSON.stringify(info);
760})()
761"#;
762
763#[must_use]
769pub fn truncate(s: &str, max_len: usize) -> String {
770 if s.len() <= max_len {
771 s.to_owned()
772 } else {
773 let cut = floor_char_boundary(s, max_len);
774 let omitted = s[cut..].chars().count();
775 format!("{}...<truncated {omitted} chars>", &s[..cut])
776 }
777}
778
779fn floor_char_boundary(s: &str, index: usize) -> usize {
781 let index = index.min(s.len());
782 let mut i = index;
783 while i > 0 && !s.is_char_boundary(i) {
784 i -= 1;
785 }
786 i
787}
788
789#[cfg(test)]
790mod tests {
791 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
794
795 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
797 ENV_LOCK
798 .lock()
799 .unwrap_or_else(std::sync::PoisonError::into_inner)
800 }
801
802 use crate::costs::extract_usage;
803 use crate::truncate;
804 use crate::{
805 default_llm_attempts, llm_base_url, llm_model, parse_headers_env, AuthConfig, AwsConfig,
806 LlmConfig, Provider,
807 };
808
809 fn mock_llm_server(status: u16, body: &'static str) -> String {
812 use std::io::{Read, Write};
813 use std::net::TcpListener;
814 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
815 let addr = listener.local_addr().unwrap();
816 std::thread::spawn(move || {
817 for stream in listener.incoming() {
818 let Ok(mut stream) = stream else { break };
819 let mut buf = [0u8; 4096];
820 let _ = stream.read(&mut buf);
821 let resp = format!(
822 "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
823 if status == 200 { "OK" } else { "ERROR" },
824 body.len(),
825 body
826 );
827 let _ = stream.write_all(resp.as_bytes());
828 }
829 });
830 format!("http://{addr}")
831 }
832
833 const PASS_BODY: &str = r#"{"choices":[{"message":{"content":"PASS"}}],"usage":{"prompt_tokens":7,"completion_tokens":2}}"#;
834
835 fn cfg(url: &str, attempts: u32) -> LlmConfig {
836 LlmConfig {
837 url: url.to_owned(),
838 model: "mock".to_owned(),
839 api_key: None,
840 headers: std::collections::HashMap::new(),
841 timeout: std::time::Duration::from_secs(10),
842 temperature: 0.0,
843 thinking: None,
844 model_params: std::collections::HashMap::new(),
845 cache: true,
846 max_attempts: attempts,
847 provider: Provider::Openai,
848 deployment: None,
849 api_version: None,
850 auth: AuthConfig::default(),
851 header_commands: std::collections::HashMap::new(),
852 aws: AwsConfig::default(),
853 }
854 }
855
856 #[tokio::test]
857 async fn test_chain_primary_success_returns_index_zero() {
858 let good = mock_llm_server(200, PASS_BODY);
859 let (resp, idx) = crate::llm_chat_with_usage_chain(&cfg(&good, 2), &[], "s", "u")
860 .await
861 .expect("primary endpoint should answer");
862 assert_eq!(idx, 0);
863 assert_eq!(resp.content, "PASS");
864 assert_eq!(resp.usage.prompt_tokens, 7);
865 }
866
867 #[tokio::test]
868 async fn test_chain_falls_back_on_empty_200() {
869 let broken = mock_llm_server(200, "");
873 let good = mock_llm_server(200, PASS_BODY);
874 let (resp, idx) =
875 crate::llm_chat_with_usage_chain(&cfg(&broken, 2), &[cfg(&good, 2)], "s", "u")
876 .await
877 .expect("fallback endpoint should answer");
878 assert_eq!(idx, 1);
879 assert_eq!(resp.content, "PASS");
880 }
881
882 #[tokio::test]
883 async fn test_chain_reports_all_endpoints_on_total_failure() {
884 let broken1 = mock_llm_server(200, "");
885 let broken2 = mock_llm_server(503, "unavailable");
886 let err =
887 crate::llm_chat_with_usage_chain(&cfg(&broken1, 2), &[cfg(&broken2, 2)], "s", "u")
888 .await
889 .expect_err("both endpoints fail");
890 assert!(err.contains("all 2 endpoint(s)"), "got: {err}");
891 assert!(err.contains(&broken1), "primary URL missing: {err}");
892 assert!(err.contains(&broken2), "fallback URL missing: {err}");
893 }
894
895 #[test]
896 fn test_default_llm_attempts_env() {
897 let _env = env_guard();
898 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "7");
899 assert_eq!(default_llm_attempts(), 7);
900 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "0");
901 assert_eq!(default_llm_attempts(), 3, "0 must fall back to default");
902 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "junk");
903 assert_eq!(default_llm_attempts(), 3, "non-numeric must fall back");
904 std::env::remove_var("HARNESS_LLM_CALL_ATTEMPTS");
905 assert_eq!(default_llm_attempts(), 3);
906 }
907
908 #[test]
909 fn test_truncate_short() {
910 assert_eq!(truncate("hello", 10), "hello");
911 }
912
913 #[test]
914 fn test_truncate_long() {
915 let result = truncate("hello world", 5);
916 assert!(result.contains("<truncated 6 chars>"));
917 assert!(result.starts_with("hello"));
918 }
919
920 #[test]
921 fn test_truncate_exact_length() {
922 assert_eq!(truncate("abcde", 5), "abcde");
923 }
924
925 #[test]
926 fn test_truncate_empty() {
927 assert_eq!(truncate("", 5), "");
928 }
929
930 #[test]
931 fn test_parse_headers_env_empty() {
932 let _env = env_guard();
933 std::env::remove_var("HARNESS_LLM_HEADERS");
934 let h = parse_headers_env();
935 assert!(h.is_empty());
936 }
937
938 #[test]
939 fn test_parse_headers_env_valid() {
940 let _env = env_guard();
941 std::env::set_var("HARNESS_LLM_HEADERS", r#"{"X-Org":"acme","X-Version":"1"}"#);
942 let h = parse_headers_env();
943 assert_eq!(h.get("X-Org").map(String::as_str), Some("acme"));
944 assert_eq!(h.get("X-Version").map(String::as_str), Some("1"));
945 std::env::remove_var("HARNESS_LLM_HEADERS");
946 }
947
948 #[test]
949 fn test_parse_headers_env_invalid_json() {
950 let _env = env_guard();
951 std::env::set_var("HARNESS_LLM_HEADERS", "not-json");
952 let h = parse_headers_env();
953 assert!(h.is_empty());
954 std::env::remove_var("HARNESS_LLM_HEADERS");
955 }
956
957 #[test]
958 fn test_llm_config_from_env_defaults() {
959 let _env = env_guard();
960 #[allow(clippy::float_cmp)]
961 {
962 let config = LlmConfig::from_env();
963 assert_eq!(config.temperature, 0.0);
964 assert!(config.thinking.is_none());
965 assert!(config.model_params.is_empty());
966 }
967 }
968
969 #[test]
970 fn test_extract_usage_full() {
971 let json = serde_json::json!({
972 "usage": {
973 "prompt_tokens": 100,
974 "completion_tokens": 200,
975 "total_tokens": 300,
976 "prompt_tokens_details": { "cached_tokens": 40 }
977 }
978 });
979 let usage = extract_usage(&json);
980 assert_eq!(usage.prompt_tokens, 100);
981 assert_eq!(usage.completion_tokens, 200);
982 assert_eq!(usage.total_tokens, 300);
983 assert_eq!(usage.cached_input_tokens, 40);
984 assert_eq!(usage.cache_creation_input_tokens, 0);
985 }
986
987 #[test]
988 fn test_extract_usage_anthropic_cache_fields() {
989 let json = serde_json::json!({
990 "usage": {
991 "prompt_tokens": 100,
992 "completion_tokens": 20,
993 "total_tokens": 120,
994 "cache_read_input_tokens": 30,
995 "cache_creation_input_tokens": 12
996 }
997 });
998 let usage = extract_usage(&json);
999 assert_eq!(usage.cached_input_tokens, 30);
1000 assert_eq!(usage.cache_creation_input_tokens, 12);
1001 }
1002
1003 #[test]
1004 fn test_extract_usage_openai_style_cache_write() {
1005 let json = serde_json::json!({
1008 "usage": {
1009 "prompt_tokens": 1566,
1010 "completion_tokens": 1518,
1011 "total_tokens": 3084,
1012 "prompt_tokens_details": {
1013 "cached_tokens": 1408,
1014 "cache_write_tokens": 100
1015 }
1016 }
1017 });
1018 let usage = extract_usage(&json);
1019 assert_eq!(usage.cached_input_tokens, 1408);
1020 assert_eq!(usage.cache_creation_input_tokens, 100);
1021 }
1022
1023 #[test]
1024 fn test_extract_usage_deepseek_cache_hit() {
1025 let json = serde_json::json!({
1027 "usage": {
1028 "prompt_tokens": 500,
1029 "completion_tokens": 50,
1030 "total_tokens": 550,
1031 "prompt_cache_hit_tokens": 400,
1032 "prompt_cache_miss_tokens": 100
1033 }
1034 });
1035 let usage = extract_usage(&json);
1036 assert_eq!(usage.cached_input_tokens, 400);
1037 assert_eq!(usage.cache_creation_input_tokens, 0);
1038 }
1039
1040 #[test]
1041 fn test_extract_usage_empty() {
1042 let json = serde_json::json!({});
1043 let usage = extract_usage(&json);
1044 assert_eq!(usage.prompt_tokens, 0);
1045 assert_eq!(usage.completion_tokens, 0);
1046 assert_eq!(usage.total_tokens, 0);
1047 }
1048
1049 #[test]
1050 fn test_truncate_unicode() {
1051 assert_eq!(truncate("héllo", 3), "hé...<truncated 3 chars>");
1054 assert_eq!(truncate("hello", 5), "hello");
1056 }
1057
1058 #[test]
1059 fn test_truncate_utf8_boundary_mid_char_does_not_panic() {
1060 let result = truncate("héllo", 2);
1066 assert_eq!(result, "h...<truncated 4 chars>");
1067
1068 let cut_inside = truncate("🎉🎉🎉 boom", 5);
1072 assert_eq!(cut_inside, "🎉...<truncated 7 chars>");
1073 assert!(is_valid_utf8(&cut_inside), "result must stay valid UTF-8");
1074 }
1075
1076 #[test]
1077 fn test_truncate_utf8_exact_omitted_count() {
1078 assert_eq!(truncate("abcdefghij", 5), "abcde...<truncated 5 chars>");
1080 assert_eq!(truncate("ééé", 6), "ééé");
1083 assert_eq!(truncate("ééé", 5), "éé...<truncated 1 chars>");
1084 }
1085
1086 fn is_valid_utf8(s: &str) -> bool {
1087 std::str::from_utf8(s.as_bytes()).is_ok()
1088 }
1089
1090 #[test]
1091 fn test_parse_headers_env_non_object() {
1092 let _env = env_guard();
1093 std::env::set_var("HARNESS_LLM_HEADERS", "[1, 2, 3]");
1094 let h = parse_headers_env();
1095 assert!(h.is_empty());
1096 std::env::remove_var("HARNESS_LLM_HEADERS");
1097 }
1098
1099 #[test]
1100 fn test_parse_headers_env_nested_values_filtered() {
1101 let _env = env_guard();
1102 std::env::set_var(
1103 "HARNESS_LLM_HEADERS",
1104 r#"{"str":"val","num":42,"bool":true}"#,
1105 );
1106 let h = parse_headers_env();
1107 assert_eq!(h.get("str").map(String::as_str), Some("val"));
1108 assert!(!h.contains_key("num"));
1109 assert!(!h.contains_key("bool"));
1110 std::env::remove_var("HARNESS_LLM_HEADERS");
1111 }
1112
1113 #[test]
1114 fn test_llm_config_has_default_model() {
1115 let _env = env_guard();
1116 let config = LlmConfig::from_env();
1117 assert_ne!(config.model, "");
1118 }
1119
1120 #[test]
1121 fn test_llm_base_url_default() {
1122 let _env = env_guard();
1123 std::env::remove_var("HARNESS_LLM_TEST_URL");
1124 let url = llm_base_url();
1125 assert_eq!(url, "http://localhost:8080");
1126 }
1127
1128 #[test]
1129 fn test_llm_base_url_custom() {
1130 let _env = env_guard();
1131 std::env::set_var("HARNESS_LLM_TEST_URL", "https://custom.api.com/v1");
1132 let url = llm_base_url();
1133 assert_eq!(url, "https://custom.api.com/v1");
1134 std::env::remove_var("HARNESS_LLM_TEST_URL");
1135 }
1136
1137 #[test]
1138 fn test_llm_base_url_trailing_slash() {
1139 let _env = env_guard();
1140 std::env::set_var("HARNESS_LLM_TEST_URL", "https://api.com/");
1141 let url = llm_base_url();
1142 assert_eq!(url, "https://api.com");
1143 std::env::remove_var("HARNESS_LLM_TEST_URL");
1144 }
1145
1146 #[test]
1147 fn test_llm_model_default() {
1148 let _env = env_guard();
1149 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1150 assert_eq!(llm_model(), "deepseek");
1151 }
1152
1153 #[test]
1154 fn test_llm_model_custom() {
1155 let _env = env_guard();
1156 std::env::set_var("HARNESS_LLM_TEST_MODEL", "gpt-4o");
1157 assert_eq!(llm_model(), "gpt-4o");
1158 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1159 }
1160
1161 #[test]
1162 fn test_extract_usage_partial() {
1163 let _env = env_guard();
1164 let json = serde_json::json!({
1165 "usage": {
1166 "prompt_tokens": 50
1167 }
1168 });
1169 let usage = extract_usage(&json);
1170 assert_eq!(usage.prompt_tokens, 50);
1171 assert_eq!(usage.completion_tokens, 0);
1172 assert_eq!(usage.total_tokens, 0);
1173 }
1174
1175 #[test]
1176 fn test_browser_headless_default() {
1177 let _env = env_guard();
1178 std::env::remove_var("HARNESS_BROWSER_HEADLESS");
1179 assert!(crate::browser_headless());
1180 }
1181}