From 5dfa66669108b8fc4c7d0cb1b80a8c533bb1948b Mon Sep 17 00:00:00 2001 From: Sampson Date: Thu, 12 Mar 2026 14:14:05 -0500 Subject: [PATCH] feat: adds context-llm tool support (#616) * feat: adds context-llm tool support Introduces a new tool for the LLM Context endpoint of the Brave Search API: https://api-dashboard.search.brave.com/documentation/services/llm-context. * minor refactoring * Update registry/tools/llm-context.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update tools-src/llm-context/llm-context-tool.capabilities.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update tools-src/llm-context/src/lib.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * chore: address feedback from review * address feedback * address feedback * fix: remove snippet-counting fn --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- registry/tools/llm-context.json | 41 + tools-src/llm-context/Cargo.toml | 23 + .../llm-context-tool.capabilities.json | 53 + tools-src/llm-context/src/lib.rs | 1339 +++++++++++++++++ 4 files changed, 1456 insertions(+) create mode 100644 registry/tools/llm-context.json create mode 100644 tools-src/llm-context/Cargo.toml create mode 100644 tools-src/llm-context/llm-context-tool.capabilities.json create mode 100644 tools-src/llm-context/src/lib.rs diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json new file mode 100644 index 00000000..a647a153 --- /dev/null +++ b/registry/tools/llm-context.json @@ -0,0 +1,41 @@ +{ + "name": "llm-context", + "display_name": "LLM Context", + "kind": "tool", + "version": "0.1.0", + "wit_version": "0.3.0", + "description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)", + "keywords": [ + "search", + "web", + "brave", + "rag", + "grounding", + "llm", + "context" + ], + "source": { + "dir": "tools-src/llm-context", + "capabilities": "llm-context-tool.capabilities.json", + "crate_name": "llm-context-tool" + }, + "artifacts": { + "wasm32-wasip2": { + "url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz", + "sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3" + } + }, + "auth_summary": { + "method": "manual", + "provider": "Brave", + "secrets": [ + "brave_api_key" + ], + "shared_auth": "Same API key as Web Search tool (brave_api_key)", + "setup_url": "https://brave.com/search/api/" + }, + "tags": [ + "default", + "search" + ] +} diff --git a/tools-src/llm-context/Cargo.toml b/tools-src/llm-context/Cargo.toml new file mode 100644 index 00000000..9f672189 --- /dev/null +++ b/tools-src/llm-context/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "llm-context-tool" +version = "0.1.0" +edition = "2021" +description = "Brave Search LLM Context tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/tools-src/llm-context/llm-context-tool.capabilities.json b/tools-src/llm-context/llm-context-tool.capabilities.json new file mode 100644 index 00000000..72061eaa --- /dev/null +++ b/tools-src/llm-context/llm-context-tool.capabilities.json @@ -0,0 +1,53 @@ +{ + "version": "0.1.0", + "wit_version": "0.3.0", + "capabilities": { + "http": { + "allowlist": [ + { + "host": "api.search.brave.com", + "path_prefix": "/res/v1/llm/context", + "methods": [ + "POST" + ] + } + ], + "credentials": { + "brave_api_key": { + "secret_name": "brave_api_key", + "location": { + "type": "header", + "name": "X-Subscription-Token" + }, + "host_patterns": [ + "api.search.brave.com" + ] + } + }, + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 500 + } + }, + "secrets": { + "allowed_names": [ + "brave_api_key" + ] + } + }, + "auth": { + "secret_name": "brave_api_key", + "display_name": "Brave Search", + "instructions": "Get a free API key at brave.com/search/api/ (Free tier: 2,000 queries/month). Same key as Web Search.", + "setup_url": "https://brave.com/search/api/", + "env_var": "BRAVE_API_KEY" + }, + "setup": { + "required_secrets": [ + { + "name": "brave_api_key", + "prompt": "Brave Search API key (from brave.com/search/api)" + } + ] + } +} diff --git a/tools-src/llm-context/src/lib.rs b/tools-src/llm-context/src/lib.rs new file mode 100644 index 00000000..59791f3b --- /dev/null +++ b/tools-src/llm-context/src/lib.rs @@ -0,0 +1,1339 @@ +//! Brave Search LLM Context WASM Tool for IronClaw. +//! +//! Fetches pre-extracted web content from the Brave Search LLM Context API, +//! optimized for grounding LLM responses (RAG, fact-checking, research). +//! +//! # Authentication +//! +//! Uses the same Brave Search API key as the Web Search tool: +//! `ironclaw secret set brave_api_key ` +//! +//! Get a key at: https://brave.com/search/api/ + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +use serde::Deserialize; + +// Brave LLM Context API endpoint documentation: +// https://api-dashboard.search.brave.com/documentation/services/llm-context +// +// This tool uses POST with a JSON body (unlike Web Search's GET + query params) to avoid +// URL length limits and support richer parameters. + +const BRAVE_LLM_CONTEXT_ENDPOINT: &str = "https://api.search.brave.com/res/v1/llm/context"; + +// Query and result limits (aligned with Brave API) +const MAX_QUERY_LEN: usize = 400; +const MAX_QUERY_WORDS: usize = 50; +const MIN_COUNT: u32 = 1; +const MAX_COUNT: u32 = 50; +const DEFAULT_COUNT: u32 = 20; +const MIN_TOKENS: u32 = 1024; +const MAX_TOKENS: u32 = 32768; +const DEFAULT_MAX_TOKENS: u32 = 8192; +const MIN_URLS: u32 = 1; +const MAX_URLS: u32 = 50; +const DEFAULT_MAX_URLS: u32 = 20; +const MIN_SNIPPETS: u32 = 1; +const MAX_SNIPPETS: u32 = 100; +const DEFAULT_MAX_SNIPPETS: u32 = 50; +const MIN_TOKENS_PER_URL: u32 = 512; +const MAX_TOKENS_PER_URL: u32 = 8192; +const DEFAULT_MAX_TOKENS_PER_URL: u32 = 4096; +const MIN_SNIPPETS_PER_URL: u32 = 1; +const MAX_SNIPPETS_PER_URL: u32 = 100; +const DEFAULT_SNIPPETS_PER_URL: u32 = 50; +const MAX_RETRIES: u32 = 3; + +// Validation helpers +const VALID_THRESHOLD_MODES: [&str; 4] = ["strict", "balanced", "lenient", "disabled"]; + +struct LlmContextTool; + +impl exports::near::agent::tool::Guest for LlmContextTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + SCHEMA.to_string() + } + + fn description() -> String { + "Fetch pre-extracted web content from Brave Search for grounding LLM answers. \ + Returns actual page content (text chunks, tables, code) relevant to the query, \ + ready for RAG or fact-checking. Supports location-aware queries via optional \ + loc_lat, loc_long, loc_city, loc_state, loc_country, etc. for local/POI results. \ + Use when you need substantive content from the web rather than just links and \ + snippets. Authentication via 'brave_api_key' (same as Web Search)." + .to_string() + } +} + +/// Input parameters for the LLM Context API. Snake_case fields map to Brave's JSON body +/// and optional X-Loc-* headers; validation happens in `validate_params`, clamping in `build_request_body`. +#[derive(Debug, Default, Deserialize)] +struct LlmContextParams { + #[serde(default)] + query: String, + country: Option, + search_lang: Option, + count: Option, + // Context Size Parameters + maximum_number_of_urls: Option, + maximum_number_of_tokens: Option, + maximum_number_of_snippets: Option, + maximum_number_of_tokens_per_url: Option, + maximum_number_of_snippets_per_url: Option, + // Filtering and Local Parameters + context_threshold_mode: Option, + goggles: Option, + // Location-aware query headers + #[serde(rename = "loc_lat")] + loc_lat: Option, + #[serde(rename = "loc_long")] + loc_long: Option, + #[serde(rename = "loc_city")] + loc_city: Option, + #[serde(rename = "loc_state")] + loc_state: Option, + #[serde(rename = "loc_state_name")] + loc_state_name: Option, + #[serde(rename = "loc_country")] + loc_country: Option, + #[serde(rename = "loc_postal_code")] + loc_postal_code: Option, +} + +/// Top-level Brave LLM Context API response: optional grounding (generic/poi/map) and optional sources map. +#[derive(Debug, Deserialize)] +struct BraveLlmContextResponse { + grounding: Option, + sources: Option>, +} + +/// Grounding content by type. See [LLM Context API](https://api-dashboard.search.brave.com/documentation/services/llm-context) and [LLM Context POST](https://api-dashboard.search.brave.com/api-reference/summarizer/llm_context/post). +#[derive(Debug, Deserialize)] +struct Grounding { + /// Main grounding data: array of URL objects with extracted content (text chunks, tables, code). + generic: Option>, + /// Point-of-interest data, sometimes present when local recall is enabled (e.g. via X-Loc-* headers or enable_local). + poi: Option, + /// Map/place results when local recall is enabled. Array of place entries with name, url, title, snippets. + map: Option>, +} + +/// One URL's extracted content in `grounding.generic`: url, title, and text snippets. +#[derive(Clone, Debug, Deserialize)] +struct GenericEntry { + url: Option, + title: Option, + snippets: Option>, +} + +/// Entry shape for `grounding.poi` (single object) and `grounding.map` (array). Present when local recall is active. +#[derive(Debug, Deserialize)] +struct PoiMapEntry { + name: Option, + url: Option, + title: Option, + snippets: Option>, +} + +/// Validate the input parameters against the schema. +fn validate_params(params: &LlmContextParams) -> Result<(), String> { + let trimmed = params.query.trim(); + if trimmed.is_empty() { + return Err("'query' must not be empty or only whitespace".into()); + } + if trimmed.chars().count() > MAX_QUERY_LEN { + return Err(format!( + "'query' exceeds maximum length of {} characters", + MAX_QUERY_LEN + )); + } + let word_count = trimmed.split_whitespace().count(); + if word_count > MAX_QUERY_WORDS { + return Err(format!( + "'query' exceeds maximum of {} words (got {})", + MAX_QUERY_WORDS, word_count + )); + } + + // Validate optional parameters (same style as Web Search tool) + if let Some(ref lang) = params.search_lang { + if !is_valid_lang_code(lang) { + return Err(format!( + "Invalid 'search_lang': expected 2-letter code like 'en', got '{lang}'" + )); + } + } + if let Some(ref country) = params.country { + if !is_valid_country_code(country) { + return Err(format!( + "Invalid 'country': expected 2-letter code like 'US', got '{country}'" + )); + } + } + if let Some(ref mode) = params.context_threshold_mode { + if !is_valid_threshold_mode(mode) { + return Err(format!( + "Invalid 'context_threshold_mode': expected 'strict', 'balanced', 'lenient', or 'disabled', got '{mode}'" + )); + } + } + + if let Some(ref goggles) = params.goggles { + if !is_valid_goggles_value(goggles) { + return Err(format!( + "Invalid 'goggles': expected a non-empty string or a non-empty array of strings (URLs or inline definitions), got '{goggles}'" + )); + } + } + + if let Some(lat) = params.loc_lat { + if !(-90.0..=90.0).contains(&lat) { + return Err(format!( + "Invalid 'loc_lat': must be between -90 and 90 (got {lat})" + )); + } + } + if let Some(long) = params.loc_long { + if !(-180.0..=180.0).contains(&long) { + return Err(format!( + "Invalid 'loc_long': must be between -180 and 180 (got {long})" + )); + } + } + if let Some(ref c) = params.loc_country { + if !is_valid_country_code(c) { + return Err(format!( + "Invalid 'loc_country': expected 2-letter uppercase code like 'US', got '{c}'" + )); + } + } + Ok(()) +} + +/// Entry point: parse, validate, call API, format output. +fn execute_inner(params: &str) -> Result { + let params: LlmContextParams = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?; + + validate_params(¶ms)?; + preflight_check()?; + + let response_body = call_brave_api(¶ms)?; + let api_response: BraveLlmContextResponse = serde_json::from_str(&response_body) + .map_err(|e| format!("Failed to parse Brave response: {e}"))?; + + format_output(¶ms.query, api_response) +} + +/// Verify the API key is available before making the request. +fn preflight_check() -> Result<(), String> { + if !near::agent::host::secret_exists("brave_api_key") { + return Err("Brave API key not found in secret store. Set it with: \ + ironclaw secret set brave_api_key . \ + Get a key at: https://brave.com/search/api/" + .into()); + } + Ok(()) +} + +/// Call the Brave LLM Context API with retry on transient server errors. +/// +/// Retries on 5xx errors only. 429 (rate limit) is not retried since the WASM +/// sandbox has no sleep primitive and immediate retry would just hit the limit again. +fn call_brave_api(params: &LlmContextParams) -> Result { + let request_body = build_request_body(params)?; + let headers = build_request_headers(params); + + let mut attempt = 0; + let response = loop { + attempt += 1; + + let resp = near::agent::host::http_request( + "POST", + BRAVE_LLM_CONTEXT_ENDPOINT, + &headers.to_string(), + Some(&request_body), + None, + ) + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if resp.status >= 200 && resp.status < 300 { + break resp; + } + + if attempt < MAX_RETRIES && resp.status >= 500 { + near::agent::host::log( + near::agent::host::LogLevel::Warn, + &format!( + "Brave LLM Context API error {} (attempt {}/{}). Retrying...", + resp.status, attempt, MAX_RETRIES + ), + ); + continue; + } + + let error_body = String::from_utf8_lossy(&resp.body); + return Err(format!( + "Brave LLM Context API error (HTTP {}): {}", + resp.status, error_body + )); + }; + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 response: {e}")) +} + +/// Normalize grounding + sources into a single JSON output. +fn format_output(query: &str, response: BraveLlmContextResponse) -> Result { + let sources = response.sources.unwrap_or_default(); + let grounding = response.grounding; + + let generic = grounding + .as_ref() + .and_then(|g| g.generic.as_deref()) + .unwrap_or_default(); + + let poi = grounding.as_ref().and_then(|g| g.poi.as_ref()); + let map = grounding + .as_ref() + .and_then(|g| g.map.as_deref()) + .unwrap_or_default(); + + // Count snippets from typed data before creating JSON for better performance and type safety. + let generic_snippet_count: usize = generic + .iter() + .map(|e| e.snippets.as_deref().unwrap_or_default().len()) + .sum(); + let poi_snippet_count: usize = poi + .map(|p| p.snippets.as_deref().unwrap_or_default().len()) + .unwrap_or(0); + let map_snippet_count: usize = map + .iter() + .map(|e| e.snippets.as_deref().unwrap_or_default().len()) + .sum(); + let snippet_count = generic_snippet_count + poi_snippet_count + map_snippet_count; + + let entries: Vec = generic + .iter() + .filter_map(|e| { + let url = e.url.as_ref()?; + let title = e.title.as_deref().unwrap_or("Untitled"); + let snippets = e.snippets.as_deref().unwrap_or(&[]); + Some(build_entry_json(url, title, None, snippets, &sources)) + }) + .collect(); + + let poi_output = poi.map(|e| poi_map_entry_to_json(e, &sources)); + + let map_output: Vec = map + .iter() + .map(|e| poi_map_entry_to_json(e, &sources)) + .collect(); + + let mut output = serde_json::json!({ + "query": query, + "url_count": entries.len(), + "snippet_count": snippet_count, + "sources": entries, + }); + + if let Some(poi) = poi_output { + output["poi"] = poi; + } + + if !map_output.is_empty() { + output["map"] = serde_json::json!(map_output); + } + + serde_json::to_string(&output).map_err(|e| format!("Failed to serialize output: {e}")) +} + +/// Build the POST request body as JSON. Clamps numeric fields to API min/max; only includes +/// optional fields when present and valid. +fn build_request_body(params: &LlmContextParams) -> Result, String> { + let count = params + .count + .unwrap_or(DEFAULT_COUNT) + .clamp(MIN_COUNT, MAX_COUNT); + let max_tokens = params + .maximum_number_of_tokens + .unwrap_or(DEFAULT_MAX_TOKENS) + .clamp(MIN_TOKENS, MAX_TOKENS); + let max_urls = params + .maximum_number_of_urls + .unwrap_or(DEFAULT_MAX_URLS) + .clamp(MIN_URLS, MAX_URLS); + let max_snippets = params + .maximum_number_of_snippets + .unwrap_or(DEFAULT_MAX_SNIPPETS) + .clamp(MIN_SNIPPETS, MAX_SNIPPETS); + let max_tokens_per_url = params + .maximum_number_of_tokens_per_url + .unwrap_or(DEFAULT_MAX_TOKENS_PER_URL) + .clamp(MIN_TOKENS_PER_URL, MAX_TOKENS_PER_URL); + let max_snippets_per_url = params + .maximum_number_of_snippets_per_url + .unwrap_or(DEFAULT_SNIPPETS_PER_URL) + .clamp(MIN_SNIPPETS_PER_URL, MAX_SNIPPETS_PER_URL); + + let mut body = serde_json::Map::new(); + body.insert( + "q".to_string(), + serde_json::Value::String(params.query.trim().to_string()), + ); + + // Insert number fields + let number_fields: [(&str, u32); 6] = [ + ("count", count), + ("maximum_number_of_tokens", max_tokens), + ("maximum_number_of_urls", max_urls), + ("maximum_number_of_snippets", max_snippets), + ("maximum_number_of_tokens_per_url", max_tokens_per_url), + ("maximum_number_of_snippets_per_url", max_snippets_per_url), + ]; + for (key, value) in number_fields { + body.insert( + key.to_string(), + serde_json::Value::Number(serde_json::Number::from(value)), + ); + } + + // Optional body fields: + let optional_body_strings: [(&str, Option); 3] = [ + ("country", params.country.clone()), + ("search_lang", params.search_lang.clone()), + ( + "context_threshold_mode", + params.context_threshold_mode.clone(), + ), + ]; + for (key, value) in optional_body_strings { + if let Some(v) = value { + body.insert(key.to_string(), serde_json::Value::String(v)); + } + } + if let Some(goggles) = params.goggles.clone() { + body.insert("goggles".to_string(), goggles); + } + + serde_json::to_vec(&serde_json::Value::Object(body)) + .map_err(|e| format!("Failed to serialize request body: {e}")) +} + +/// Build HTTP request headers: Accept, Content-Type, User-Agent, and optional X-Loc-* +/// for location-aware queries. API key is injected by the host (same as Web Search). +fn build_request_headers(params: &LlmContextParams) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert( + "Accept".to_string(), + serde_json::Value::String("application/json".to_string()), + ); + map.insert( + "Content-Type".to_string(), + serde_json::Value::String("application/json".to_string()), + ); + map.insert( + "User-Agent".to_string(), + serde_json::Value::String("IronClaw-LlmContext-Tool/0.1".to_string()), + ); + + // Location-aware headers: (X-Loc-* name, optional value from params) + let loc_headers: [(&str, Option); 7] = [ + ("X-Loc-Lat", params.loc_lat.map(|v| v.to_string())), + ("X-Loc-Long", params.loc_long.map(|v| v.to_string())), + ("X-Loc-City", params.loc_city.clone()), + ("X-Loc-State", params.loc_state.clone()), + ("X-Loc-State-Name", params.loc_state_name.clone()), + ("X-Loc-Country", params.loc_country.clone()), + ("X-Loc-Postal-Code", params.loc_postal_code.clone()), + ]; + for (header, value) in loc_headers { + if let Some(v) = value { + map.insert(header.to_string(), serde_json::Value::String(v)); + } + } + + serde_json::Value::Object(map) +} + +/// Builds a JSON object for a search result entry. +fn build_entry_json( + url: &str, + title: &str, + name: Option<&str>, + snippets: &[String], + sources: &serde_json::Map, +) -> serde_json::Value { + let hostname = sources + .get(url) + .and_then(|v| v.get("hostname")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| extract_hostname(url).unwrap_or_default()); + + let age_str = sources + .get(url) + .and_then(|v| v.get("age")) + .and_then(|v| v.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()); + + let mut entry = serde_json::json!({ + "url": url, + "title": title, + "hostname": hostname, + "snippets": snippets, + }); + + if let Some(name) = name { + entry["name"] = serde_json::json!(name); + } + if let Some(age) = age_str { + entry["age"] = serde_json::json!(age); + } + + entry +} + +/// Build a JSON object for a POI or map entry (name, url, title, hostname, snippets, age when available). +fn poi_map_entry_to_json( + e: &PoiMapEntry, + sources: &serde_json::Map, +) -> serde_json::Value { + let url = e.url.as_deref().unwrap_or_default(); + let title = e.title.as_deref().unwrap_or("Untitled"); + let name = e.name.as_deref(); + let snippets = e.snippets.as_deref().unwrap_or(&[]); + build_entry_json(url, title, name, snippets, sources) +} + +/// Extract hostname from a URL string (no URL parser dependency). Handles http(s) and strips port. +fn extract_hostname(url: &str) -> Option { + let after_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let host = after_scheme.split('/').next()?; + let host = host.split(':').next()?; + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +/// Validate a 2-letter language code (e.g. "en", "de"). +fn is_valid_lang_code(s: &str) -> bool { + s.len() == 2 && s.bytes().all(|b| b.is_ascii_lowercase()) +} + +/// Validate a 2-letter country code (e.g. "US", "DE"). +fn is_valid_country_code(s: &str) -> bool { + s.len() == 2 && s.bytes().all(|b| b.is_ascii_uppercase()) +} + +/// Validate context_threshold_mode: strict, balanced, lenient, or disabled. +fn is_valid_threshold_mode(s: &str) -> bool { + VALID_THRESHOLD_MODES.contains(&s) +} + +/// Goggles must be a non-empty string or a non-empty array of strings (URLs or inline definitions). +fn is_valid_goggles_value(v: &serde_json::Value) -> bool { + match v { + serde_json::Value::String(s) => !s.is_empty(), + serde_json::Value::Array(a) => { + !a.is_empty() + && a.iter() + .all(|e| matches!(e, serde_json::Value::String(s) if !s.is_empty())) + } + _ => false, + } +} + +// Schema must remain in sync with the MIN_*, DEFAULT_*, and MAX_* constants. +const SCHEMA: &str = r#"{ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query; returns pre-extracted web content (text, tables, code) for grounding LLM answers", + "minLength": 1, + "maxLength": 400 + }, + "count": { + "type": "integer", + "description": "Maximum number of search results to consider (1-50, default 20)", + "minimum": 1, + "maximum": 50, + "default": 20 + }, + "country": { + "type": "string", + "description": "2-letter uppercase country code (e.g. 'US', 'DE')" + }, + "search_lang": { + "type": "string", + "description": "2-letter lowercase language code for results (e.g. 'en', 'de')" + }, + "maximum_number_of_tokens": { + "type": "integer", + "description": "Approximate max tokens in returned context (1024-32768, default 8192)", + "minimum": 1024, + "maximum": 32768, + "default": 8192 + }, + "maximum_number_of_urls": { + "type": "integer", + "description": "Maximum URLs to include (1-50, default 20)", + "minimum": 1, + "maximum": 50, + "default": 20 + }, + "maximum_number_of_snippets": { + "type": "integer", + "description": "Maximum snippets across all URLs (1-100, default 50)", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "maximum_number_of_tokens_per_url": { + "type": "integer", + "description": "Max tokens per URL (512-8192, default 4096)", + "minimum": 512, + "maximum": 8192, + "default": 4096 + }, + "maximum_number_of_snippets_per_url": { + "type": "integer", + "description": "Max snippets per URL (1-100, default 50)", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "context_threshold_mode": { + "type": "string", + "description": "Relevance filter: 'strict' (fewer, more relevant), 'balanced', 'lenient', or 'disabled'", + "enum": ["strict", "balanced", "lenient", "disabled"] + }, + "loc_lat": { + "type": "number", + "description": "Latitude for location-aware queries (-90 to 90). Use with loc_long or place-name headers for local/POI results." + }, + "loc_long": { + "type": "number", + "description": "Longitude for location-aware queries (-180 to 180). Use with loc_lat or place-name headers for local/POI results." + }, + "loc_city": { + "type": "string", + "description": "City name for location-aware queries (e.g. 'San Francisco')" + }, + "loc_state": { + "type": "string", + "description": "State/region code for location-aware queries (e.g. 'CA', ISO 3166-2)" + }, + "loc_state_name": { + "type": "string", + "description": "State/region full name for location-aware queries" + }, + "loc_country": { + "type": "string", + "description": "2-letter uppercase country code for location headers (e.g. 'US'). Enables local recall for queries like 'coffee shops near me'." + }, + "loc_postal_code": { + "type": "string", + "description": "Postal code for location-aware queries" + }, + "goggles": { + "description": "Custom ranking/filtering: URL to a Goggle file, inline Goggles rules, or array of URLs/inline strings. Restrict or boost sources (e.g. trusted domains). See https://api-dashboard.search.brave.com/documentation/resources/goggles", + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 } + ] + } + }, + "required": ["query"], + "additionalProperties": false +}"#; + +export!(LlmContextTool); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_hostname() { + assert_eq!( + extract_hostname("https://example.com/path"), + Some("example.com".into()) + ); + assert_eq!( + extract_hostname("http://example.com"), + Some("example.com".into()) + ); + assert_eq!( + extract_hostname("http://host:8080/path"), + Some("host".into()) + ); + assert_eq!( + extract_hostname("https://sub.example.com:443/"), + Some("sub.example.com".into()) + ); + assert_eq!(extract_hostname("https://"), None); + assert_eq!(extract_hostname("https:///path"), None); + assert_eq!(extract_hostname("ftp://example.com"), None); + assert_eq!(extract_hostname("example.com"), None); + assert_eq!(extract_hostname(""), None); + } + + #[test] + fn test_is_valid_lang_code() { + assert!(is_valid_lang_code("en")); + assert!(!is_valid_lang_code("EN")); + assert!(!is_valid_lang_code("eng")); + } + + #[test] + fn test_is_valid_country_code() { + assert!(is_valid_country_code("US")); + assert!(!is_valid_country_code("us")); + assert!(!is_valid_country_code("USA")); + } + + #[test] + fn test_is_valid_threshold_mode() { + assert!(is_valid_threshold_mode("strict")); + assert!(is_valid_threshold_mode("balanced")); + assert!(is_valid_threshold_mode("lenient")); + assert!(is_valid_threshold_mode("disabled")); + assert!(!is_valid_threshold_mode("invalid")); + } + + fn params_minimal() -> LlmContextParams { + LlmContextParams { + query: "rust async".to_string(), + ..Default::default() + } + } + + #[test] + fn test_validate_params_accepts_minimal() { + let params = params_minimal(); + assert!(validate_params(¶ms).is_ok()); + } + + #[test] + fn test_validate_params_rejects_invalid() { + // Empty query + let mut p = params_minimal(); + p.query = "".to_string(); + assert!(validate_params(&p).is_err()); + + // Query too long + p.query = "a".repeat(MAX_QUERY_LEN + 1); + assert!(validate_params(&p).is_err()); + + // Too many words + p.query = (0..MAX_QUERY_WORDS + 1) + .map(|i| format!("w{i}")) + .collect::>() + .join(" "); + assert!(validate_params(&p).is_err()); + + // Invalid search_lang (must be 2-letter lowercase) + p = params_minimal(); + p.search_lang = Some("EN".to_string()); + assert!(validate_params(&p).is_err()); + + // Invalid country (must be 2-letter uppercase) + p = params_minimal(); + p.country = Some("us".to_string()); + assert!(validate_params(&p).is_err()); + + // Invalid context_threshold_mode + p = params_minimal(); + p.context_threshold_mode = Some("invalid".to_string()); + assert!(validate_params(&p).is_err()); + + // Invalid loc_lat (out of range) + p = params_minimal(); + p.loc_lat = Some(91.0); + assert!(validate_params(&p).is_err()); + + // Invalid loc_long (out of range) + p = params_minimal(); + p.loc_long = Some(-181.0); + assert!(validate_params(&p).is_err()); + + // Invalid loc_country + p = params_minimal(); + p.loc_country = Some("usa".to_string()); + assert!(validate_params(&p).is_err()); + + // Invalid goggles (empty string) + p = params_minimal(); + p.goggles = Some(serde_json::Value::String(String::new())); + assert!(validate_params(&p).is_err()); + } + + #[test] + fn test_build_request_body_minimal() { + let params = params_minimal(); + let body = build_request_body(¶ms).unwrap(); + let obj: serde_json::Map = + serde_json::from_slice(&body).unwrap(); + assert_eq!(obj.get("q").and_then(|v| v.as_str()), Some("rust async")); + assert_eq!(obj.get("count").and_then(|v| v.as_u64()), Some(20)); + assert_eq!( + obj.get("maximum_number_of_tokens").and_then(|v| v.as_u64()), + Some(8192) + ); + assert!(!obj.contains_key("country")); + assert!(!obj.contains_key("context_threshold_mode")); + } + + #[test] + fn test_build_request_body_full() { + let params = LlmContextParams { + query: "python asyncio".to_string(), + count: Some(10), + country: Some("US".to_string()), + search_lang: Some("en".to_string()), + maximum_number_of_tokens: Some(4096), + maximum_number_of_urls: Some(10), + maximum_number_of_snippets: Some(25), + maximum_number_of_tokens_per_url: Some(2048), + maximum_number_of_snippets_per_url: Some(25), + context_threshold_mode: Some("strict".to_string()), + ..Default::default() + }; + let body = build_request_body(¶ms).unwrap(); + let obj: serde_json::Map = + serde_json::from_slice(&body).unwrap(); + assert_eq!( + obj.get("q").and_then(|v| v.as_str()), + Some("python asyncio") + ); + assert_eq!(obj.get("count").and_then(|v| v.as_u64()), Some(10)); + assert_eq!(obj.get("country").and_then(|v| v.as_str()), Some("US")); + assert_eq!(obj.get("search_lang").and_then(|v| v.as_str()), Some("en")); + assert_eq!( + obj.get("maximum_number_of_tokens").and_then(|v| v.as_u64()), + Some(4096) + ); + assert_eq!( + obj.get("context_threshold_mode").and_then(|v| v.as_str()), + Some("strict") + ); + } + + #[test] + fn test_build_request_headers_with_location() { + let params = LlmContextParams { + query: "coffee shops".to_string(), + loc_lat: Some(37.7749), + loc_long: Some(-122.4194), + loc_city: Some("San Francisco".to_string()), + loc_state: Some("CA".to_string()), + loc_state_name: Some("California".to_string()), + loc_country: Some("US".to_string()), + loc_postal_code: Some("94102".to_string()), + ..Default::default() + }; + let headers = build_request_headers(¶ms); + let obj = headers.as_object().unwrap(); + assert_eq!( + obj.get("Accept").and_then(|v| v.as_str()), + Some("application/json") + ); + assert_eq!( + obj.get("X-Loc-Lat").and_then(|v| v.as_str()), + Some("37.7749") + ); + assert_eq!( + obj.get("X-Loc-Long").and_then(|v| v.as_str()), + Some("-122.4194") + ); + assert_eq!( + obj.get("X-Loc-City").and_then(|v| v.as_str()), + Some("San Francisco") + ); + assert_eq!(obj.get("X-Loc-State").and_then(|v| v.as_str()), Some("CA")); + assert_eq!( + obj.get("X-Loc-State-Name").and_then(|v| v.as_str()), + Some("California") + ); + assert_eq!( + obj.get("X-Loc-Country").and_then(|v| v.as_str()), + Some("US") + ); + assert_eq!( + obj.get("X-Loc-Postal-Code").and_then(|v| v.as_str()), + Some("94102") + ); + } + + #[test] + fn test_build_request_headers_no_location() { + let params = params_minimal(); + let headers = build_request_headers(¶ms); + let obj = headers.as_object().unwrap(); + assert_eq!( + obj.get("Accept").and_then(|v| v.as_str()), + Some("application/json") + ); + assert_eq!( + obj.get("Content-Type").and_then(|v| v.as_str()), + Some("application/json") + ); + assert!(obj.get("User-Agent").is_some()); + assert!(obj.get("X-Loc-Lat").is_none()); + assert!(obj.get("X-Loc-Country").is_none()); + } + + #[test] + fn test_build_request_body_with_goggles_string() { + let mut params = params_minimal(); + params.query = "rust programming".to_string(); + params.goggles = Some(serde_json::Value::String( + "https://raw.githubusercontent.com/brave/goggles-quickstart/main/goggles/tech_blogs.goggle" + .to_string(), + )); + let body = build_request_body(¶ms).unwrap(); + let obj: serde_json::Map = + serde_json::from_slice(&body).unwrap(); + assert_eq!( + obj.get("goggles").and_then(|v| v.as_str()), + Some("https://raw.githubusercontent.com/brave/goggles-quickstart/main/goggles/tech_blogs.goggle") + ); + } + + #[test] + fn test_build_request_body_with_goggles_array() { + let mut params = params_minimal(); + params.query = "web development".to_string(); + params.goggles = Some(serde_json::json!([ + "https://example.com/goggle1.goggle", + "$boost=3,site=dev.to" + ])); + let body = build_request_body(¶ms).unwrap(); + let obj: serde_json::Map = + serde_json::from_slice(&body).unwrap(); + let arr = obj.get("goggles").and_then(|v| v.as_array()).unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].as_str(), Some("https://example.com/goggle1.goggle")); + assert_eq!(arr[1].as_str(), Some("$boost=3,site=dev.to")); + } + + #[test] + fn test_is_valid_goggles_value() { + assert!(is_valid_goggles_value(&serde_json::Value::String( + "https://x.com/a.goggle".to_string() + ))); + assert!(is_valid_goggles_value(&serde_json::json!([ + "https://a.com", + "$boost,site=dev.to" + ]))); + assert!(!is_valid_goggles_value(&serde_json::Value::String( + "".to_string() + ))); + assert!(!is_valid_goggles_value(&serde_json::Value::Array(vec![]))); + assert!(!is_valid_goggles_value(&serde_json::Value::Bool(true))); + } + + #[test] + fn test_parse_response() { + let body = r#"{ + "grounding": { + "generic": [ + { + "url": "https://example.com/page", + "title": "Example Page", + "snippets": ["First snippet.", "Second snippet."] + } + ] + }, + "sources": { + "https://example.com/page": { + "title": "Example Page", + "hostname": "example.com", + "age": ["2024-01-15", "380 days ago"] + } + } + }"#; + let r: BraveLlmContextResponse = serde_json::from_str(body).unwrap(); + let generic = r.grounding.unwrap().generic.unwrap(); + assert_eq!(generic.len(), 1); + assert_eq!(generic[0].url.as_deref(), Some("https://example.com/page")); + assert_eq!(generic[0].title.as_deref(), Some("Example Page")); + assert_eq!(generic[0].snippets.as_ref().unwrap().len(), 2); + let sources = r.sources.unwrap(); + let meta = sources.get("https://example.com/page").unwrap(); + assert_eq!( + meta.get("hostname").and_then(|v| v.as_str()), + Some("example.com") + ); + } + + #[test] + fn test_parse_response_with_poi_and_map() { + let body = r#"{ + "grounding": { + "generic": [{"url": "https://example.com/page", "title": "Example", "snippets": []}], + "poi": { + "name": "Business Name", + "url": "https://business.com", + "title": "Title of business.com website", + "snippets": ["Business details."] + }, + "map": [ + { + "name": "Place Name", + "url": "https://place.com", + "title": "Title of place.com", + "snippets": ["Place information."] + } + ] + }, + "sources": { + "https://business.com": {"title": "Business Name", "hostname": "business.com", "age": null}, + "https://place.com": {"title": "Place", "hostname": "place.com", "age": null} + } + }"#; + let r: BraveLlmContextResponse = serde_json::from_str(body).unwrap(); + let g = r.grounding.as_ref().unwrap(); + assert_eq!(g.generic.as_ref().unwrap().len(), 1); + let poi = g.poi.as_ref().unwrap(); + assert_eq!(poi.name.as_deref(), Some("Business Name")); + assert_eq!(poi.url.as_deref(), Some("https://business.com")); + assert_eq!(poi.snippets.as_ref().unwrap().len(), 1); + let map = g.map.as_ref().unwrap(); + assert_eq!(map.len(), 1); + assert_eq!(map[0].name.as_deref(), Some("Place Name")); + assert_eq!(map[0].url.as_deref(), Some("https://place.com")); + } + + #[test] + fn test_poi_map_entry_to_json() { + let e = PoiMapEntry { + name: Some("Cafe Example".to_string()), + url: Some("https://cafe.example.com".to_string()), + title: Some("Cafe Example - Coffee".to_string()), + snippets: Some(vec!["Best coffee in town.".to_string()]), + }; + let mut sources = serde_json::Map::new(); + sources.insert( + "https://cafe.example.com".to_string(), + serde_json::json!({"hostname": "cafe.example.com", "age": ["2024-06-01"]}), + ); + let out = poi_map_entry_to_json(&e, &sources); + assert_eq!( + out.get("name").and_then(|v| v.as_str()), + Some("Cafe Example") + ); + assert_eq!( + out.get("url").and_then(|v| v.as_str()), + Some("https://cafe.example.com") + ); + assert_eq!( + out.get("hostname").and_then(|v| v.as_str()), + Some("cafe.example.com") + ); + assert_eq!(out.get("age").and_then(|v| v.as_str()), Some("2024-06-01")); + let snippets = out.get("snippets").and_then(|s| s.as_array()).unwrap(); + assert_eq!(snippets.len(), 1); + assert_eq!(snippets[0].as_str(), Some("Best coffee in town.")); + } + + #[test] + fn test_build_request_body_clamps_below_min() { + let mut params = params_minimal(); + params.count = Some(0); + params.maximum_number_of_tokens = Some(100); + params.maximum_number_of_urls = Some(0); + params.maximum_number_of_snippets = Some(0); + params.maximum_number_of_tokens_per_url = Some(1); + params.maximum_number_of_snippets_per_url = Some(0); + + let body = build_request_body(¶ms).unwrap(); + let obj: serde_json::Map = + serde_json::from_slice(&body).unwrap(); + + assert_eq!(obj["count"].as_u64(), Some(MIN_COUNT as u64)); + assert_eq!( + obj["maximum_number_of_tokens"].as_u64(), + Some(MIN_TOKENS as u64) + ); + assert_eq!( + obj["maximum_number_of_urls"].as_u64(), + Some(MIN_URLS as u64) + ); + assert_eq!( + obj["maximum_number_of_snippets"].as_u64(), + Some(MIN_SNIPPETS as u64) + ); + assert_eq!( + obj["maximum_number_of_tokens_per_url"].as_u64(), + Some(MIN_TOKENS_PER_URL as u64) + ); + assert_eq!( + obj["maximum_number_of_snippets_per_url"].as_u64(), + Some(MIN_SNIPPETS_PER_URL as u64) + ); + } + + #[test] + fn test_build_request_body_clamps_above_max() { + let mut params = params_minimal(); + params.count = Some(999); + params.maximum_number_of_tokens = Some(999_999); + params.maximum_number_of_urls = Some(999); + params.maximum_number_of_snippets = Some(999); + params.maximum_number_of_tokens_per_url = Some(999_999); + params.maximum_number_of_snippets_per_url = Some(999); + + let body = build_request_body(¶ms).unwrap(); + let obj: serde_json::Map = + serde_json::from_slice(&body).unwrap(); + + assert_eq!(obj["count"].as_u64(), Some(MAX_COUNT as u64)); + assert_eq!( + obj["maximum_number_of_tokens"].as_u64(), + Some(MAX_TOKENS as u64) + ); + assert_eq!( + obj["maximum_number_of_urls"].as_u64(), + Some(MAX_URLS as u64) + ); + assert_eq!( + obj["maximum_number_of_snippets"].as_u64(), + Some(MAX_SNIPPETS as u64) + ); + assert_eq!( + obj["maximum_number_of_tokens_per_url"].as_u64(), + Some(MAX_TOKENS_PER_URL as u64) + ); + assert_eq!( + obj["maximum_number_of_snippets_per_url"].as_u64(), + Some(MAX_SNIPPETS_PER_URL as u64) + ); + } + + #[test] + fn test_build_entry_json_missing_source() { + let sources = serde_json::Map::new(); + let entry = build_entry_json( + "https://unknown.com/page", + "Title", + None, + &["snippet".to_string()], + &sources, + ); + assert_eq!( + entry.get("hostname").and_then(|v| v.as_str()), + Some("unknown.com") + ); + assert!(entry.get("age").is_none()); + } + + #[test] + fn test_build_entry_json_with_name() { + let sources = serde_json::Map::new(); + let entry = build_entry_json( + "https://example.com", + "Title", + Some("My Place"), + &[], + &sources, + ); + assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("My Place")); + } + + #[test] + fn test_parse_empty_grounding_response() { + let body = r#"{"grounding": null, "sources": null}"#; + let r: BraveLlmContextResponse = serde_json::from_str(body).unwrap(); + assert!(r.grounding.is_none()); + assert!(r.sources.is_none()); + } + + #[test] + fn test_parse_empty_generic_array() { + let body = r#"{"grounding": {"generic": []}, "sources": {}}"#; + let r: BraveLlmContextResponse = serde_json::from_str(body).unwrap(); + assert!(r.grounding.unwrap().generic.unwrap().is_empty()); + } + + #[test] + fn test_format_output_empty_response() { + let response = BraveLlmContextResponse { + grounding: None, + sources: None, + }; + let result = format_output("test query", response).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["query"].as_str(), Some("test query")); + assert_eq!(parsed["url_count"].as_u64(), Some(0)); + assert_eq!(parsed["snippet_count"].as_u64(), Some(0)); + assert!(parsed["sources"].as_array().unwrap().is_empty()); + assert!(parsed.get("poi").is_none()); + assert!(parsed.get("map").is_none()); + } + + #[test] + fn test_format_output_with_generic_entries() { + let response = BraveLlmContextResponse { + grounding: Some(Grounding { + generic: Some(vec![ + GenericEntry { + url: Some("https://example.com".to_string()), + title: Some("Example".to_string()), + snippets: Some(vec!["s1".to_string(), "s2".to_string()]), + }, + GenericEntry { + url: None, + title: Some("No URL".to_string()), + snippets: None, + }, + ]), + poi: None, + map: None, + }), + sources: Some({ + let mut m = serde_json::Map::new(); + m.insert( + "https://example.com".to_string(), + serde_json::json!({"hostname": "example.com", "age": ["2024-01-01"]}), + ); + m + }), + }; + let result = format_output("test", response).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["url_count"].as_u64(), Some(1)); + assert_eq!(parsed["snippet_count"].as_u64(), Some(2)); + let first = &parsed["sources"][0]; + assert_eq!(first["hostname"].as_str(), Some("example.com")); + assert_eq!(first["age"].as_str(), Some("2024-01-01")); + } + + #[test] + fn test_format_output_with_poi_and_map() { + let response = BraveLlmContextResponse { + grounding: Some(Grounding { + generic: Some(vec![]), + poi: Some(PoiMapEntry { + name: Some("Coffee Shop".to_string()), + url: Some("https://coffee.com".to_string()), + title: Some("Coffee".to_string()), + snippets: Some(vec!["Great beans.".to_string()]), + }), + map: Some(vec![PoiMapEntry { + name: Some("Place".to_string()), + url: Some("https://place.com".to_string()), + title: Some("Place".to_string()), + snippets: Some(vec!["Info.".to_string(), "More info.".to_string()]), + }]), + }), + sources: None, + }; + let result = format_output("coffee", response).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["snippet_count"].as_u64(), Some(3)); + assert_eq!(parsed["poi"]["name"].as_str(), Some("Coffee Shop")); + assert_eq!(parsed["map"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_schema_is_valid_json_and_matches_constants() { + let schema: serde_json::Value = + serde_json::from_str(SCHEMA).expect("SCHEMA must be valid JSON"); + let props = schema["properties"].as_object().unwrap(); + + let count = &props["count"]; + assert_eq!(count["minimum"].as_u64(), Some(MIN_COUNT as u64)); + assert_eq!(count["maximum"].as_u64(), Some(MAX_COUNT as u64)); + assert_eq!(count["default"].as_u64(), Some(DEFAULT_COUNT as u64)); + + let max_tokens = &props["maximum_number_of_tokens"]; + assert_eq!(max_tokens["minimum"].as_u64(), Some(MIN_TOKENS as u64)); + assert_eq!(max_tokens["maximum"].as_u64(), Some(MAX_TOKENS as u64)); + assert_eq!( + max_tokens["default"].as_u64(), + Some(DEFAULT_MAX_TOKENS as u64) + ); + + let max_urls = &props["maximum_number_of_urls"]; + assert_eq!(max_urls["minimum"].as_u64(), Some(MIN_URLS as u64)); + assert_eq!(max_urls["maximum"].as_u64(), Some(MAX_URLS as u64)); + assert_eq!(max_urls["default"].as_u64(), Some(DEFAULT_MAX_URLS as u64)); + + let max_snippets = &props["maximum_number_of_snippets"]; + assert_eq!(max_snippets["minimum"].as_u64(), Some(MIN_SNIPPETS as u64)); + assert_eq!(max_snippets["maximum"].as_u64(), Some(MAX_SNIPPETS as u64)); + assert_eq!( + max_snippets["default"].as_u64(), + Some(DEFAULT_MAX_SNIPPETS as u64) + ); + + let max_tpu = &props["maximum_number_of_tokens_per_url"]; + assert_eq!(max_tpu["minimum"].as_u64(), Some(MIN_TOKENS_PER_URL as u64)); + assert_eq!(max_tpu["maximum"].as_u64(), Some(MAX_TOKENS_PER_URL as u64)); + assert_eq!( + max_tpu["default"].as_u64(), + Some(DEFAULT_MAX_TOKENS_PER_URL as u64) + ); + + let max_spu = &props["maximum_number_of_snippets_per_url"]; + assert_eq!( + max_spu["minimum"].as_u64(), + Some(MIN_SNIPPETS_PER_URL as u64) + ); + assert_eq!( + max_spu["maximum"].as_u64(), + Some(MAX_SNIPPETS_PER_URL as u64) + ); + assert_eq!( + max_spu["default"].as_u64(), + Some(DEFAULT_SNIPPETS_PER_URL as u64) + ); + + let query = &props["query"]; + assert_eq!(query["maxLength"].as_u64(), Some(MAX_QUERY_LEN as u64)); + } + + #[test] + fn test_validate_params_trimmed_query_within_limit() { + let mut p = params_minimal(); + p.query = format!(" {} ", "a".repeat(MAX_QUERY_LEN - 4)); + assert!( + validate_params(&p).is_ok(), + "trimmed query within limit should pass" + ); + } + + #[test] + fn test_validate_params_trimmed_query_over_limit() { + let mut p = params_minimal(); + p.query = format!(" {} ", "a".repeat(MAX_QUERY_LEN + 1)); + assert!( + validate_params(&p).is_err(), + "trimmed query over limit should fail" + ); + } +}