Skip to main content

llm_browser_testkit/
lib.rs

1//! LLM-driven browser test framework.
2//!
3//! Provides reusable building blocks for browser-based test scenarios:
4//! - Browser client via Chrome `DevTools` Protocol (headless)
5//! - LLM client for natural language element targeting and assertions
6//! - A2A agent integration for agent-to-agent communication
7//! - MCP client/server integration for tool-calling
8//! - Cost tracking, token counting, and budget enforcement
9//! - Declarative TOML scenario runner
10//! - `#[browser_test]` macros for `cargo test` integration
11
12#![allow(
13    clippy::expect_used,
14    clippy::unwrap_used,
15    clippy::panic,
16    clippy::missing_panics_doc
17)]
18
19/// A2A agent protocol client.
20pub mod a2a;
21/// Auth token resolution — Entra ID and token commands for LLM endpoints.
22mod auth;
23/// AWS Bedrock provider (`SigV4`-signed Converse calls; feature `aws`).
24#[cfg(feature = "aws")]
25mod bedrock;
26/// Budget tracking and enforcement.
27pub mod budgets;
28/// Cost calculation, usage tracking, and pricing.
29pub mod costs;
30/// Failure diagnostics — page-state capture and artifact writing.
31pub mod diagnostics;
32/// Endpoint registry and routing resolver.
33pub mod endpoints;
34/// Typed run events emitted by the runner.
35pub mod events;
36/// MCP client for connecting to external MCP servers.
37pub mod mcp_client;
38/// MCP server for exposing the framework as an MCP server.
39pub mod mcp_server;
40/// Concurrent execution of multiple scenario files across isolated browsers.
41pub mod parallel;
42/// Secret redaction for every report sink.
43pub mod redact;
44/// Run reporting: console, NDJSON, JUnit, GitHub and Perfetto sinks.
45pub mod reporting;
46/// Step-by-step scenario executor (navigate, click, type, wait, assert).
47pub mod runner;
48/// Declarative TOML-based test scenario types.
49pub mod scenario;
50/// CSS selector sanitization for LLM-generated selectors.
51pub mod selectors;
52/// Vision support — screenshot capture/downscale/encode for visual asserts.
53pub mod vision;
54
55/// A2A agent server for accepting agent tasks.
56#[cfg(feature = "a2a-server")]
57pub mod a2a_server;
58
59/// `#[browser_test]` macros for `cargo test` integration.
60#[cfg(feature = "macros")]
61pub mod macros;
62
63use std::collections::HashMap;
64use std::time::Duration;
65
66use serde_json::{json, Value};
67
68pub use costs::LlmResponse;
69pub use costs::LlmUsage;
70pub use scenario::AuthConfig;
71pub use scenario::AuthMode;
72pub use scenario::AwsConfig;
73pub use scenario::Provider;
74
75/// Configuration for the LLM client — bundles URL, model, auth, timeouts,
76/// and provider-specific options into a single struct passed everywhere.
77#[derive(Debug, Clone)]
78pub struct LlmConfig {
79    /// OpenAI-compatible API base URL (without trailing `/v1/…`).
80    pub url: String,
81    /// Model name (e.g. `gpt-4o-mini`, `deepseek`).
82    pub model: String,
83    /// API key sent as `Authorization: Bearer <key>`.
84    pub api_key: Option<String>,
85    /// Custom headers appended to every LLM request.
86    pub headers: HashMap<String, String>,
87    /// HTTP timeout.
88    pub timeout: Duration,
89    /// Sampling temperature (0.0–1.0).
90    pub temperature: f64,
91    /// Enable extended thinking / reasoning tokens.
92    /// `None` = don't send any thinking key (provider default).
93    pub thinking: Option<bool>,
94    /// Provider-specific parameters merged into the request body
95    /// (e.g. `effort = "high"` for Anthropic).
96    pub model_params: HashMap<String, Value>,
97    /// How many times a single call to this endpoint is retried on
98    /// transient failures before giving up (or moving to the next fallback
99    /// endpoint). Default 3; override globally with
100    /// `HARNESS_LLM_CALL_ATTEMPTS`.
101    pub max_attempts: u32,
102    /// LLM provider protocol (defaults to OpenAI-compatible).
103    ///
104    /// `azure` switches to the `Azure` `OpenAI` deployments endpoint, `bedrock`
105    /// to the `SigV4`-signed AWS Bedrock Converse API (feature `aws`).
106    pub provider: Provider,
107    /// `Azure` `OpenAI` deployment name (`Provider::Azure`). Defaults to
108    /// `model` when unset.
109    pub deployment: Option<String>,
110    /// `Azure` `OpenAI` API version (`Provider::Azure`). Defaults to
111    /// `2024-10-21`.
112    pub api_version: Option<String>,
113    /// Authentication configuration (API key, token command, Entra ID).
114    pub auth: AuthConfig,
115    /// Extra HTTP headers produced by running a command per call, keyed by
116    /// header name. Provider-agnostic.
117    pub header_commands: HashMap<String, String>,
118    /// AWS credential settings (`Provider::Bedrock`).
119    pub aws: AwsConfig,
120}
121
122impl Default for LlmConfig {
123    fn default() -> Self {
124        Self {
125            url: String::new(),
126            model: String::new(),
127            api_key: None,
128            headers: HashMap::new(),
129            timeout: Duration::from_secs(60),
130            temperature: 0.0,
131            thinking: None,
132            model_params: HashMap::new(),
133            max_attempts: default_llm_attempts(),
134            provider: Provider::Openai,
135            deployment: None,
136            api_version: None,
137            auth: AuthConfig::default(),
138            header_commands: HashMap::new(),
139            aws: AwsConfig::default(),
140        }
141    }
142}
143
144impl LlmConfig {
145    /// Build a config from environment defaults, falling back to safe
146    /// values when no env vars are set.
147    #[must_use]
148    pub fn from_env() -> Self {
149        Self {
150            url: llm_base_url(),
151            model: llm_model(),
152            api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
153            headers: parse_headers_env(),
154            timeout: Duration::from_secs(60),
155            temperature: 0.0,
156            thinking: None,
157            model_params: HashMap::new(),
158            max_attempts: default_llm_attempts(),
159            provider: Provider::Openai,
160            deployment: None,
161            api_version: None,
162            auth: AuthConfig::default(),
163            header_commands: HashMap::new(),
164            aws: AwsConfig::default(),
165        }
166    }
167}
168
169/// Default `Azure` `OpenAI` API version used when an endpoint does not set
170/// `api_version`.
171pub const DEFAULT_AZURE_API_VERSION: &str = "2024-10-21";
172
173/// Builds the `Azure` `OpenAI` chat completions URL: the resource endpoint
174/// (without `/openai`), the deployment name, and the API version.
175#[must_use]
176pub fn build_azure_url(base: &str, deployment: &str, api_version: &str) -> String {
177    let base = base.trim_end_matches('/');
178    let base = base
179        .strip_suffix("/openai")
180        .unwrap_or(base)
181        .trim_end_matches('/');
182    format!("{base}/openai/deployments/{deployment}/chat/completions?api-version={api_version}")
183}
184
185/// Reads `HARNESS_LLM_CALL_ATTEMPTS` (default 3) — how many times a single
186/// chat completion is retried before the endpoint is considered failed.
187#[must_use]
188pub fn default_llm_attempts() -> u32 {
189    std::env::var("HARNESS_LLM_CALL_ATTEMPTS")
190        .ok()
191        .and_then(|v| v.parse().ok())
192        .filter(|n| *n >= 1)
193        .unwrap_or(3)
194}
195
196/// Parses `HARNESS_LLM_HEADERS` env var (JSON object) into a header map.
197///
198/// Exposed for use by `endpoints.rs` and tests.
199#[must_use]
200pub fn parse_headers_env() -> HashMap<String, String> {
201    let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
202        return HashMap::new();
203    };
204    let Ok(json) = serde_json::from_str::<Value>(&raw) else {
205        return HashMap::new();
206    };
207    let Some(obj) = json.as_object() else {
208        return HashMap::new();
209    };
210    obj.iter()
211        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
212        .collect()
213}
214
215/// Returns the target base URL from `HARNESS_BROWSER_BASE_URL` env,
216/// defaulting to `http://localhost:4200`.
217#[must_use]
218pub fn base_url() -> String {
219    std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
220}
221
222/// Returns the LLM server base URL from `HARNESS_LLM_TEST_URL` env,
223/// defaulting to `http://localhost:8080`.
224#[must_use]
225pub fn llm_base_url() -> String {
226    std::env::var("HARNESS_LLM_TEST_URL")
227        .unwrap_or_else(|_| "http://localhost:8080".to_owned())
228        .trim_end_matches('/')
229        .to_owned()
230}
231
232/// Returns the LLM model name from `HARNESS_LLM_TEST_MODEL` env,
233/// defaulting to `deepseek`.
234#[must_use]
235pub fn llm_model() -> String {
236    std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
237}
238
239/// Returns whether to run the browser in headless mode from
240/// `HARNESS_BROWSER_HEADLESS` env, defaulting to `true`.
241#[must_use]
242pub fn browser_headless() -> bool {
243    std::env::var("HARNESS_BROWSER_HEADLESS")
244        .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
245}
246
247/// Builds a `reqwest::Client` with the given timeout.
248#[must_use]
249pub fn http_client(timeout: Duration) -> reqwest::Client {
250    reqwest::Client::builder()
251        .timeout(timeout)
252        .build()
253        .expect("build reqwest client")
254}
255
256/// Sends a chat completion request to the LLM.
257///
258/// Returns `Some(content)` on success, `None` on any error.
259///
260/// Prefer `llm_chat_with_usage` if you need token counting.
261#[must_use]
262pub async fn llm_chat(llm: &LlmConfig, system: &str, user: &str) -> Option<String> {
263    llm_chat_with_usage(llm, system, user)
264        .await
265        .map(|r| r.content)
266        .ok()
267}
268
269/// Sends a chat completion request to the LLM and returns both the content
270/// and token usage from the API response.
271///
272/// Retries transient failures (network errors, HTTP 429/5xx, invalid
273/// responses, and HTTP 200 + empty body — a gateway warm-up signature,
274/// retried with a 3s backoff) up to `llm.max_attempts` times, and returns
275/// the last underlying error instead of collapsing everything into a
276/// generic "server down" message. The error text includes the HTTP status
277/// and a truncated response-body snippet, so a gateway that answers with
278/// an HTML error page is identifiable in CI logs instead of surfacing as a
279/// bare JSON decode error. Deterministic client errors (401/403/404) are
280/// not retried.
281///
282/// # Errors
283///
284/// Returns the last underlying error as a human-readable string when every
285/// attempt fails (transport error, non-success HTTP status, response that is
286/// not valid JSON, an empty HTTP 200 body, or a response missing
287/// `choices[0].message.content`).
288pub async fn llm_chat_with_usage(
289    llm: &LlmConfig,
290    system: &str,
291    user: &str,
292) -> Result<LlmResponse, String> {
293    chat_with_retry(llm, system, user, None).await
294}
295
296/// Sends a vision-enabled chat completion request.
297///
298/// The user message carries the text prompt and one or more screenshots
299/// (JPEG/PNG data URLs, ordered from the top of the page down) as
300/// OpenAI-compatible `image_url` content parts.
301///
302/// Retries and error reporting behave like [`llm_chat_with_usage`].
303///
304/// # Errors
305///
306/// Returns the last underlying error as a human-readable string when every
307/// attempt fails (transport error, non-success HTTP status, response that is
308/// not valid JSON, or a response missing `choices[0].message.content`).
309pub async fn llm_chat_vision_with_usage(
310    llm: &LlmConfig,
311    system: &str,
312    user: &str,
313    image_data_urls: Option<&[String]>,
314) -> Result<LlmResponse, String> {
315    chat_with_retry(llm, system, user, image_data_urls).await
316}
317
318/// Calls a chain of endpoints: the primary [`LlmConfig`] first, then each
319/// fallback in order. Every endpoint gets its own `max_attempts` retry
320/// budget; the first endpoint that answers wins.
321///
322/// Returns the response together with the index of the endpoint that
323/// produced it (0 = primary, 1 = first fallback, …) so the caller can
324/// attribute cost/usage to the right endpoint.
325///
326/// # Errors
327///
328/// Returns an error naming every endpoint that failed.
329pub async fn llm_chat_with_usage_chain(
330    primary: &LlmConfig,
331    fallbacks: &[LlmConfig],
332    system: &str,
333    user: &str,
334) -> Result<(LlmResponse, usize), String> {
335    chat_chain_with_retry(primary, fallbacks, system, user, None).await
336}
337
338/// Vision variant of [`llm_chat_with_usage_chain`].
339///
340/// # Errors
341///
342/// Returns an error naming every endpoint that failed.
343pub async fn llm_chat_vision_with_usage_chain(
344    primary: &LlmConfig,
345    fallbacks: &[LlmConfig],
346    system: &str,
347    user: &str,
348    image_data_urls: Option<&[String]>,
349) -> Result<(LlmResponse, usize), String> {
350    chat_chain_with_retry(primary, fallbacks, system, user, image_data_urls).await
351}
352
353/// Shared chain loop: try each endpoint (primary then fallbacks) with its
354/// own retry budget; first success wins.
355async fn chat_chain_with_retry(
356    primary: &LlmConfig,
357    fallbacks: &[LlmConfig],
358    system: &str,
359    user: &str,
360    image_data_urls: Option<&[String]>,
361) -> Result<(LlmResponse, usize), String> {
362    let mut failures: Vec<String> = Vec::new();
363    for (i, llm) in std::iter::once(primary).chain(fallbacks.iter()).enumerate() {
364        match chat_with_retry(llm, system, user, image_data_urls).await {
365            Ok(resp) => return Ok((resp, i)),
366            Err(e) => failures.push(format!("endpoint '{}' ({:?}): {e}", llm.url, llm.model)),
367        }
368    }
369    let details = failures.iter().fold(String::new(), |mut acc, f| {
370        use std::fmt::Write as _;
371        let _ = writeln!(acc, "  - {f}");
372        acc
373    });
374    Err(format!(
375        "LLM call failed on all {} endpoint(s):\n{details}",
376        failures.len()
377    ))
378}
379
380/// Shared retry loop for text-only and vision chat completions.
381async fn chat_with_retry(
382    llm: &LlmConfig,
383    system: &str,
384    user: &str,
385    image_data_urls: Option<&[String]>,
386) -> Result<LlmResponse, String> {
387    let client = http_client(llm.timeout);
388    let mut last_err = String::from("LLM call failed");
389    let mut attempts: u32 = 0;
390
391    while attempts < llm.max_attempts {
392        attempts += 1;
393        match llm_chat_once(&client, llm, system, user, image_data_urls).await {
394            Ok(resp) => return Ok(resp),
395            Err(err) => {
396                // An HTTP 200 with an empty body is a gateway warm-up
397                // signature: give it a longer window to finish booting
398                // instead of hammering it with short retries.
399                let backoff = match &err {
400                    LlmCallError::EmptyBody { .. } => Duration::from_secs(3),
401                    _ => Duration::from_millis(500 * u64::from(attempts)),
402                };
403                last_err = err.to_string();
404                if attempts >= llm.max_attempts || !err.is_retryable() {
405                    break;
406                }
407                tokio::time::sleep(backoff).await;
408            }
409        }
410    }
411
412    Err(format!(
413        "LLM call failed after {attempts} attempt(s) (endpoint {url}): {last_err}",
414        url = llm.url
415    ))
416}
417
418/// Builds the chat messages array. Text-only messages keep the plain
419/// string `content` shape (maximum provider compatibility); vision calls
420/// use the OpenAI-compatible content-part array with one `image_url` part
421/// per screenshot (ordered from the top of the page down).
422#[must_use]
423fn build_messages(system: &str, user: &str, image_data_urls: Option<&[String]>) -> Value {
424    let user_content = image_data_urls.map_or_else(
425        || Value::String(user.to_owned()),
426        |urls| {
427            let mut parts = vec![json!({"type": "text", "text": user})];
428            for url in urls {
429                parts.push(json!({"type": "image_url", "image_url": {"url": url}}));
430            }
431            Value::Array(parts)
432        },
433    );
434    json!([
435        {"role": "system", "content": system},
436        {"role": "user", "content": user_content}
437    ])
438}
439
440/// Internal error type for a single LLM request attempt, distinguishing
441/// transient failures (worth retrying) from deterministic configuration
442/// errors (fail immediately).
443enum LlmCallError {
444    /// Transport-level failure (connect, timeout, TLS, …).
445    Transport { message: String },
446    /// Non-success HTTP status with a body snippet.
447    Http { status: u16, body: String },
448    /// Success status but the body is not valid JSON.
449    InvalidJson {
450        status: u16,
451        detail: String,
452        body: String,
453    },
454    /// Success status with an EMPTY body — the classic transient gateway
455    /// warm-up signature (HTTP 200, zero bytes). Retried with a longer
456    /// backoff than other errors.
457    EmptyBody { status: u16 },
458    /// Valid JSON but missing `choices[0].message.content`.
459    MissingContent { json: String },
460    /// Authentication failure: token command failed, Entra endpoint
461    /// answered with an error, credentials missing, or an unsupported
462    /// provider/feature combination.
463    Auth { message: String },
464}
465
466impl std::fmt::Display for LlmCallError {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        match self {
469            Self::Transport { message } => write!(f, "LLM HTTP request failed: {message}"),
470            Self::Http { status, body } => {
471                write!(
472                    f,
473                    "LLM endpoint returned HTTP {status}: {}",
474                    truncate(body, 300)
475                )
476            }
477            Self::InvalidJson {
478                status,
479                detail,
480                body,
481            } => write!(
482                f,
483                "LLM endpoint returned HTTP {status} with non-JSON body ({detail}): {}",
484                truncate(body, 300)
485            ),
486            Self::EmptyBody { status } => write!(
487                f,
488                "LLM endpoint returned HTTP {status} with an empty response (likely gateway warm-up)"
489            ),
490            Self::MissingContent { json } => write!(
491                f,
492                "LLM response missing choices[0].message.content: {}",
493                truncate(json, 300)
494            ),
495            Self::Auth { message } => write!(f, "LLM authentication failed: {message}"),
496        }
497    }
498}
499
500impl LlmCallError {
501    /// Whether another attempt may succeed. Network errors, rate limits,
502    /// server errors, and 200-with-garbage responses can be transient on
503    /// flaky gateways; auth/not-found errors are deterministic.
504    #[must_use]
505    fn is_retryable(&self) -> bool {
506        match self {
507            Self::Transport { .. }
508            | Self::MissingContent { .. }
509            | Self::EmptyBody { .. }
510            | Self::Auth { .. } => true,
511            Self::Http { status, .. } => {
512                *status == 408 || *status == 429 || (500..600).contains(status)
513            }
514            Self::InvalidJson { status, .. } => {
515                *status == 200 || *status == 408 || *status == 429 || (500..600).contains(status)
516            }
517        }
518    }
519}
520
521/// Single LLM chat request attempt; returns the underlying error as text.
522async fn llm_chat_once(
523    client: &reqwest::Client,
524    llm: &LlmConfig,
525    system: &str,
526    user: &str,
527    image_data_urls: Option<&[String]>,
528) -> Result<LlmResponse, LlmCallError> {
529    match llm.provider {
530        Provider::Openai | Provider::Azure => {
531            chat_openai_compat_once(client, llm, system, user, image_data_urls).await
532        }
533        Provider::Bedrock => {
534            #[cfg(feature = "aws")]
535            let result =
536                crate::bedrock::chat_once(client, llm, system, user, image_data_urls).await;
537            #[cfg(not(feature = "aws"))]
538            let result = Err(LlmCallError::Auth {
539                message:
540                    "provider = \"bedrock\" requires building llm-browser-testkit with the `aws` \
541                     cargo feature"
542                        .to_owned(),
543            });
544            result
545        }
546    }
547}
548
549/// Single attempt against the OpenAI-compatible path, shared by the
550/// `openai` and `azure` providers (`Azure` differs only in the URL and the
551/// API-key header placement).
552async fn chat_openai_compat_once(
553    client: &reqwest::Client,
554    llm: &LlmConfig,
555    system: &str,
556    user: &str,
557    image_data_urls: Option<&[String]>,
558) -> Result<LlmResponse, LlmCallError> {
559    let url = match llm.provider {
560        Provider::Openai => format!("{}/v1/chat/completions", llm.url),
561        Provider::Azure => {
562            let deployment = llm.deployment.clone().unwrap_or_else(|| llm.model.clone());
563            let api_version = llm
564                .api_version
565                .clone()
566                .unwrap_or_else(|| DEFAULT_AZURE_API_VERSION.to_owned());
567            build_azure_url(&llm.url, &deployment, &api_version)
568        }
569        Provider::Bedrock => unreachable!("bedrock is dispatched before this function"),
570    };
571
572    let mut headers: Vec<(String, String)> = Vec::new();
573    if llm.auth.mode == AuthMode::ApiKey {
574        match (&llm.auth.api_key_header, &llm.api_key) {
575            // Explicit header name wins on every provider.
576            (Some(header_name), Some(key)) => {
577                headers.push((header_name.clone(), key.clone()));
578            }
579            (Some(header_name), None) => {
580                return Err(LlmCallError::Auth {
581                    message: format!(
582                        "auth.api_key_header `{header_name}` requires endpoint api_key to be set"
583                    ),
584                });
585            }
586            // Azure classic auth: the key travels in the `api-key` header.
587            (None, Some(key)) if llm.provider == Provider::Azure => {
588                headers.push(("api-key".to_owned(), key.clone()));
589            }
590            // OpenAI-compatible convention.
591            (None, Some(key)) => {
592                headers.push(("Authorization".to_owned(), format!("Bearer {key}")));
593            }
594            (None, None) => {}
595        }
596    } else if let Some(bearer) = auth::resolve_bearer_token(&llm.auth, llm.api_key.as_deref())
597        .await
598        .map_err(|message| LlmCallError::Auth { message })?
599    {
600        headers.push(("Authorization".to_owned(), format!("Bearer {bearer}")));
601    }
602    for (name, value) in &llm.headers {
603        headers.push((name.clone(), value.clone()));
604    }
605    for (name, command) in &llm.header_commands {
606        let value = auth::run_header_command(command)
607            .await
608            .map_err(|e| LlmCallError::Auth {
609                message: format!("header command for `{name}` failed: {e}"),
610            })?;
611        headers.push((name.clone(), value));
612    }
613
614    let payload = build_openai_payload(llm, system, user, image_data_urls);
615
616    let mut req = client.post(&url).header("Content-Type", "application/json");
617
618    for (name, value) in headers {
619        req = req.header(name.as_str(), value.as_str());
620    }
621
622    let resp = req
623        .json(&payload)
624        .send()
625        .await
626        .map_err(|e| LlmCallError::Transport {
627            message: e.to_string(),
628        })?;
629    let status = resp.status();
630    let status_u16 = status.as_u16();
631    let body = resp.text().await.unwrap_or_default();
632    if !status.is_success() {
633        return Err(LlmCallError::Http {
634            status: status_u16,
635            body,
636        });
637    }
638    if body.trim().is_empty() {
639        // HTTP 200 + empty body: a transient gateway hiccup (cold-start /
640        // warm-up), not a client error. Retried with a longer backoff.
641        return Err(LlmCallError::EmptyBody { status: status_u16 });
642    }
643    let json: Value = match serde_json::from_str(&body) {
644        Ok(v) => v,
645        Err(e) => {
646            return Err(LlmCallError::InvalidJson {
647                status: status_u16,
648                detail: e.to_string(),
649                body,
650            });
651        }
652    };
653    let usage = costs::extract_usage(&json);
654    let content = json["choices"][0]["message"]["content"]
655        .as_str()
656        .map(String::from)
657        .ok_or_else(|| LlmCallError::MissingContent {
658            json: json.to_string(),
659        })?;
660
661    Ok(LlmResponse { content, usage })
662}
663
664/// Builds the OpenAI-compatible chat completions request body (shared by the
665/// `openai` and `azure` providers).
666#[must_use]
667fn build_openai_payload(
668    llm: &LlmConfig,
669    system: &str,
670    user: &str,
671    image_data_urls: Option<&[String]>,
672) -> Value {
673    let mut payload = serde_json::json!({
674        "model": llm.model,
675        "messages": build_messages(system, user, image_data_urls),
676        "max_tokens": 4096,
677        "temperature": llm.temperature
678    });
679    if let Some(think) = llm.thinking {
680        if think {
681            payload["thinking"] = serde_json::json!({"type": "enabled"});
682        } else {
683            payload["thinking"] = serde_json::json!({"type": "disabled"});
684        }
685    }
686    // Merge provider-specific parameters into the request body.
687    if !llm.model_params.is_empty() {
688        if let Value::Object(ref mut map) = payload {
689            for (key, val) in &llm.model_params {
690                map.insert(key.clone(), val.clone());
691            }
692        }
693    }
694    payload
695}
696
697/// JavaScript to extract interactive elements from the current page.
698/// Returns a JSON array of objects with tag, selector, and label.
699pub const DOM_EXTRACT_JS: &str = r#"
700(() => {
701  const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
702  const els = document.querySelectorAll(interactive);
703  const info = [];
704  const seen = new Set();
705  els.forEach((el, i) => {
706    const rect = el.getBoundingClientRect();
707    if (rect.width === 0 || rect.height === 0) return;
708    const tag = el.tagName.toLowerCase();
709    let selector = '';
710    if (el.id) selector = '#' + CSS.escape(el.id);
711    else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
712    else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
713    else if (el.className && typeof el.className === 'string') {
714      const cls = el.className.trim().split(/\\s+/)[0];
715      if (cls) selector = tag + '.' + CSS.escape(cls);
716    }
717    if (!selector) selector = tag;
718    if (seen.has(selector)) return;
719    seen.add(selector);
720
721    let label = '';
722    const aria = el.getAttribute('aria-label');
723    if (aria) {
724      label = aria;
725    } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
726      label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
727      if (el.type && !label) label = el.type;
728    } else {
729      label = (el.textContent || '').trim().substring(0, 80);
730    }
731
732    info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
733  });
734  return JSON.stringify(info);
735})()
736"#;
737
738/// Truncates a string to the given maximum length, appending a marker with
739/// the number of omitted characters if truncation occurred.
740///
741/// The cut point is always a UTF-8 char boundary, so multi-byte input (umlauts,
742/// emoji, CJK) can never panic the caller.
743#[must_use]
744pub fn truncate(s: &str, max_len: usize) -> String {
745    if s.len() <= max_len {
746        s.to_owned()
747    } else {
748        let cut = floor_char_boundary(s, max_len);
749        let omitted = s[cut..].chars().count();
750        format!("{}...<truncated {omitted} chars>", &s[..cut])
751    }
752}
753
754/// Returns the largest char boundary index in `s` that is `<= index`.
755fn floor_char_boundary(s: &str, index: usize) -> usize {
756    let index = index.min(s.len());
757    let mut i = index;
758    while i > 0 && !s.is_char_boundary(i) {
759        i -= 1;
760    }
761    i
762}
763
764#[cfg(test)]
765mod tests {
766    /// Serializes env-var-mutating tests: they race when the test binary runs
767    /// them in parallel, which intermittently failed CI.
768    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
769
770    /// Acquires the env lock for one test.
771    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
772        ENV_LOCK
773            .lock()
774            .unwrap_or_else(std::sync::PoisonError::into_inner)
775    }
776
777    use crate::costs::extract_usage;
778    use crate::truncate;
779    use crate::{
780        default_llm_attempts, llm_base_url, llm_model, parse_headers_env, AuthConfig, AwsConfig,
781        LlmConfig, Provider,
782    };
783
784    /// Starts a minimal HTTP server that answers every chat-completions
785    /// request with `status`/`body`. Returns its base URL.
786    fn mock_llm_server(status: u16, body: &'static str) -> String {
787        use std::io::{Read, Write};
788        use std::net::TcpListener;
789        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
790        let addr = listener.local_addr().unwrap();
791        std::thread::spawn(move || {
792            for stream in listener.incoming() {
793                let Ok(mut stream) = stream else { break };
794                let mut buf = [0u8; 4096];
795                let _ = stream.read(&mut buf);
796                let resp = format!(
797                    "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
798                    if status == 200 { "OK" } else { "ERROR" },
799                    body.len(),
800                    body
801                );
802                let _ = stream.write_all(resp.as_bytes());
803            }
804        });
805        format!("http://{addr}")
806    }
807
808    const PASS_BODY: &str = r#"{"choices":[{"message":{"content":"PASS"}}],"usage":{"prompt_tokens":7,"completion_tokens":2}}"#;
809
810    fn cfg(url: &str, attempts: u32) -> LlmConfig {
811        LlmConfig {
812            url: url.to_owned(),
813            model: "mock".to_owned(),
814            api_key: None,
815            headers: std::collections::HashMap::new(),
816            timeout: std::time::Duration::from_secs(10),
817            temperature: 0.0,
818            thinking: None,
819            model_params: std::collections::HashMap::new(),
820            max_attempts: attempts,
821            provider: Provider::Openai,
822            deployment: None,
823            api_version: None,
824            auth: AuthConfig::default(),
825            header_commands: std::collections::HashMap::new(),
826            aws: AwsConfig::default(),
827        }
828    }
829
830    #[tokio::test]
831    async fn test_chain_primary_success_returns_index_zero() {
832        let good = mock_llm_server(200, PASS_BODY);
833        let (resp, idx) = crate::llm_chat_with_usage_chain(&cfg(&good, 2), &[], "s", "u")
834            .await
835            .expect("primary endpoint should answer");
836        assert_eq!(idx, 0);
837        assert_eq!(resp.content, "PASS");
838        assert_eq!(resp.usage.prompt_tokens, 7);
839    }
840
841    #[tokio::test]
842    async fn test_chain_falls_back_on_empty_200() {
843        // Primary always returns HTTP 200 with an EMPTY body (the gateway
844        // warm-up signature) — after `max_attempts` it must hand over to the
845        // fallback, which answers properly.
846        let broken = mock_llm_server(200, "");
847        let good = mock_llm_server(200, PASS_BODY);
848        let (resp, idx) =
849            crate::llm_chat_with_usage_chain(&cfg(&broken, 2), &[cfg(&good, 2)], "s", "u")
850                .await
851                .expect("fallback endpoint should answer");
852        assert_eq!(idx, 1);
853        assert_eq!(resp.content, "PASS");
854    }
855
856    #[tokio::test]
857    async fn test_chain_reports_all_endpoints_on_total_failure() {
858        let broken1 = mock_llm_server(200, "");
859        let broken2 = mock_llm_server(503, "unavailable");
860        let err =
861            crate::llm_chat_with_usage_chain(&cfg(&broken1, 2), &[cfg(&broken2, 2)], "s", "u")
862                .await
863                .expect_err("both endpoints fail");
864        assert!(err.contains("all 2 endpoint(s)"), "got: {err}");
865        assert!(err.contains(&broken1), "primary URL missing: {err}");
866        assert!(err.contains(&broken2), "fallback URL missing: {err}");
867    }
868
869    #[test]
870    fn test_default_llm_attempts_env() {
871        let _env = env_guard();
872        std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "7");
873        assert_eq!(default_llm_attempts(), 7);
874        std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "0");
875        assert_eq!(default_llm_attempts(), 3, "0 must fall back to default");
876        std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "junk");
877        assert_eq!(default_llm_attempts(), 3, "non-numeric must fall back");
878        std::env::remove_var("HARNESS_LLM_CALL_ATTEMPTS");
879        assert_eq!(default_llm_attempts(), 3);
880    }
881
882    #[test]
883    fn test_truncate_short() {
884        assert_eq!(truncate("hello", 10), "hello");
885    }
886
887    #[test]
888    fn test_truncate_long() {
889        let result = truncate("hello world", 5);
890        assert!(result.contains("<truncated 6 chars>"));
891        assert!(result.starts_with("hello"));
892    }
893
894    #[test]
895    fn test_truncate_exact_length() {
896        assert_eq!(truncate("abcde", 5), "abcde");
897    }
898
899    #[test]
900    fn test_truncate_empty() {
901        assert_eq!(truncate("", 5), "");
902    }
903
904    #[test]
905    fn test_parse_headers_env_empty() {
906        let _env = env_guard();
907        std::env::remove_var("HARNESS_LLM_HEADERS");
908        let h = parse_headers_env();
909        assert!(h.is_empty());
910    }
911
912    #[test]
913    fn test_parse_headers_env_valid() {
914        let _env = env_guard();
915        std::env::set_var("HARNESS_LLM_HEADERS", r#"{"X-Org":"acme","X-Version":"1"}"#);
916        let h = parse_headers_env();
917        assert_eq!(h.get("X-Org").map(String::as_str), Some("acme"));
918        assert_eq!(h.get("X-Version").map(String::as_str), Some("1"));
919        std::env::remove_var("HARNESS_LLM_HEADERS");
920    }
921
922    #[test]
923    fn test_parse_headers_env_invalid_json() {
924        let _env = env_guard();
925        std::env::set_var("HARNESS_LLM_HEADERS", "not-json");
926        let h = parse_headers_env();
927        assert!(h.is_empty());
928        std::env::remove_var("HARNESS_LLM_HEADERS");
929    }
930
931    #[test]
932    fn test_llm_config_from_env_defaults() {
933        let _env = env_guard();
934        #[allow(clippy::float_cmp)]
935        {
936            let config = LlmConfig::from_env();
937            assert_eq!(config.temperature, 0.0);
938            assert!(config.thinking.is_none());
939            assert!(config.model_params.is_empty());
940        }
941    }
942
943    #[test]
944    fn test_extract_usage_full() {
945        let json = serde_json::json!({
946            "usage": {
947                "prompt_tokens": 100,
948                "completion_tokens": 200,
949                "total_tokens": 300,
950                "prompt_tokens_details": { "cached_tokens": 40 }
951            }
952        });
953        let usage = extract_usage(&json);
954        assert_eq!(usage.prompt_tokens, 100);
955        assert_eq!(usage.completion_tokens, 200);
956        assert_eq!(usage.total_tokens, 300);
957        assert_eq!(usage.cached_input_tokens, 40);
958        assert_eq!(usage.cache_creation_input_tokens, 0);
959    }
960
961    #[test]
962    fn test_extract_usage_anthropic_cache_fields() {
963        let json = serde_json::json!({
964            "usage": {
965                "prompt_tokens": 100,
966                "completion_tokens": 20,
967                "total_tokens": 120,
968                "cache_read_input_tokens": 30,
969                "cache_creation_input_tokens": 12
970            }
971        });
972        let usage = extract_usage(&json);
973        assert_eq!(usage.cached_input_tokens, 30);
974        assert_eq!(usage.cache_creation_input_tokens, 12);
975    }
976
977    #[test]
978    fn test_extract_usage_openai_style_cache_write() {
979        // OpenRouter / Azure OpenAI (GPT-5.6+) report cache writes under
980        // prompt_tokens_details.cache_write_tokens alongside cached_tokens.
981        let json = serde_json::json!({
982            "usage": {
983                "prompt_tokens": 1566,
984                "completion_tokens": 1518,
985                "total_tokens": 3084,
986                "prompt_tokens_details": {
987                    "cached_tokens": 1408,
988                    "cache_write_tokens": 100
989                }
990            }
991        });
992        let usage = extract_usage(&json);
993        assert_eq!(usage.cached_input_tokens, 1408);
994        assert_eq!(usage.cache_creation_input_tokens, 100);
995    }
996
997    #[test]
998    fn test_extract_usage_deepseek_cache_hit() {
999        // DeepSeek reports cache hits under prompt_cache_hit_tokens.
1000        let json = serde_json::json!({
1001            "usage": {
1002                "prompt_tokens": 500,
1003                "completion_tokens": 50,
1004                "total_tokens": 550,
1005                "prompt_cache_hit_tokens": 400,
1006                "prompt_cache_miss_tokens": 100
1007            }
1008        });
1009        let usage = extract_usage(&json);
1010        assert_eq!(usage.cached_input_tokens, 400);
1011        assert_eq!(usage.cache_creation_input_tokens, 0);
1012    }
1013
1014    #[test]
1015    fn test_extract_usage_empty() {
1016        let json = serde_json::json!({});
1017        let usage = extract_usage(&json);
1018        assert_eq!(usage.prompt_tokens, 0);
1019        assert_eq!(usage.completion_tokens, 0);
1020        assert_eq!(usage.total_tokens, 0);
1021    }
1022
1023    #[test]
1024    fn test_truncate_unicode() {
1025        // max_len is a byte budget; the cut lands on a char boundary.
1026        // 'é' is 2 bytes, so index 3 captures "hé" (h=0, é=bytes 1-2)
1027        assert_eq!(truncate("héllo", 3), "hé...<truncated 3 chars>");
1028        // Length 5 captures full string (5 bytes)
1029        assert_eq!(truncate("hello", 5), "hello");
1030    }
1031
1032    #[test]
1033    fn test_truncate_utf8_boundary_mid_char_does_not_panic() {
1034        // Previously: &s[..2] panicked with "not a char boundary" because the
1035        // cut landed inside the 2-byte 'é'. The runner hit this on real pages
1036        // full of umlauts/emoji — inside the diagnostics path that was
1037        // supposed to save the run.
1038        // floor_char_boundary(2) lands BEFORE 'é' (byte 1), keeping "h".
1039        let result = truncate("héllo", 2);
1040        assert_eq!(result, "h...<truncated 4 chars>");
1041
1042        // 4-byte emoji: max_len=4 lands exactly on the first 🎉 boundary? No —
1043        // boundary 0 is the largest <= 4 only if 4 is a boundary; it is, so
1044        // cut=4 keeps "🎉". Check with 5 instead: cut back to 4.
1045        let cut_inside = truncate("🎉🎉🎉 boom", 5);
1046        assert_eq!(cut_inside, "🎉...<truncated 7 chars>");
1047        assert!(is_valid_utf8(&cut_inside), "result must stay valid UTF-8");
1048    }
1049
1050    #[test]
1051    fn test_truncate_utf8_exact_omitted_count() {
1052        // 5 ASCII chars cut at 10 bytes → 5 omitted
1053        assert_eq!(truncate("abcdefghij", 5), "abcde...<truncated 5 chars>");
1054        // 3 multibyte chars cut at exactly their boundary → 0... but the
1055        // guard `len <= max_len` returns the raw string first.
1056        assert_eq!(truncate("ééé", 6), "ééé");
1057        assert_eq!(truncate("ééé", 5), "éé...<truncated 1 chars>");
1058    }
1059
1060    fn is_valid_utf8(s: &str) -> bool {
1061        std::str::from_utf8(s.as_bytes()).is_ok()
1062    }
1063
1064    #[test]
1065    fn test_parse_headers_env_non_object() {
1066        let _env = env_guard();
1067        std::env::set_var("HARNESS_LLM_HEADERS", "[1, 2, 3]");
1068        let h = parse_headers_env();
1069        assert!(h.is_empty());
1070        std::env::remove_var("HARNESS_LLM_HEADERS");
1071    }
1072
1073    #[test]
1074    fn test_parse_headers_env_nested_values_filtered() {
1075        let _env = env_guard();
1076        std::env::set_var(
1077            "HARNESS_LLM_HEADERS",
1078            r#"{"str":"val","num":42,"bool":true}"#,
1079        );
1080        let h = parse_headers_env();
1081        assert_eq!(h.get("str").map(String::as_str), Some("val"));
1082        assert!(!h.contains_key("num"));
1083        assert!(!h.contains_key("bool"));
1084        std::env::remove_var("HARNESS_LLM_HEADERS");
1085    }
1086
1087    #[test]
1088    fn test_llm_config_has_default_model() {
1089        let _env = env_guard();
1090        let config = LlmConfig::from_env();
1091        assert!(!config.model.is_empty());
1092    }
1093
1094    #[test]
1095    fn test_llm_base_url_default() {
1096        let _env = env_guard();
1097        std::env::remove_var("HARNESS_LLM_TEST_URL");
1098        let url = llm_base_url();
1099        assert_eq!(url, "http://localhost:8080");
1100    }
1101
1102    #[test]
1103    fn test_llm_base_url_custom() {
1104        let _env = env_guard();
1105        std::env::set_var("HARNESS_LLM_TEST_URL", "https://custom.api.com/v1");
1106        let url = llm_base_url();
1107        assert_eq!(url, "https://custom.api.com/v1");
1108        std::env::remove_var("HARNESS_LLM_TEST_URL");
1109    }
1110
1111    #[test]
1112    fn test_llm_base_url_trailing_slash() {
1113        let _env = env_guard();
1114        std::env::set_var("HARNESS_LLM_TEST_URL", "https://api.com/");
1115        let url = llm_base_url();
1116        assert_eq!(url, "https://api.com");
1117        std::env::remove_var("HARNESS_LLM_TEST_URL");
1118    }
1119
1120    #[test]
1121    fn test_llm_model_default() {
1122        let _env = env_guard();
1123        std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1124        assert_eq!(llm_model(), "deepseek");
1125    }
1126
1127    #[test]
1128    fn test_llm_model_custom() {
1129        let _env = env_guard();
1130        std::env::set_var("HARNESS_LLM_TEST_MODEL", "gpt-4o");
1131        assert_eq!(llm_model(), "gpt-4o");
1132        std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1133    }
1134
1135    #[test]
1136    fn test_extract_usage_partial() {
1137        let _env = env_guard();
1138        let json = serde_json::json!({
1139            "usage": {
1140                "prompt_tokens": 50
1141            }
1142        });
1143        let usage = extract_usage(&json);
1144        assert_eq!(usage.prompt_tokens, 50);
1145        assert_eq!(usage.completion_tokens, 0);
1146        assert_eq!(usage.total_tokens, 0);
1147    }
1148
1149    #[test]
1150    fn test_browser_headless_default() {
1151        let _env = env_guard();
1152        std::env::remove_var("HARNESS_BROWSER_HEADLESS");
1153        assert!(crate::browser_headless());
1154    }
1155}