mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
* 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>
1340 lines
48 KiB
Rust
1340 lines
48 KiB
Rust
//! 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 <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<String>,
|
|
search_lang: Option<String>,
|
|
count: Option<u32>,
|
|
// Context Size Parameters
|
|
maximum_number_of_urls: Option<u32>,
|
|
maximum_number_of_tokens: Option<u32>,
|
|
maximum_number_of_snippets: Option<u32>,
|
|
maximum_number_of_tokens_per_url: Option<u32>,
|
|
maximum_number_of_snippets_per_url: Option<u32>,
|
|
// Filtering and Local Parameters
|
|
context_threshold_mode: Option<String>,
|
|
goggles: Option<serde_json::Value>,
|
|
// Location-aware query headers
|
|
#[serde(rename = "loc_lat")]
|
|
loc_lat: Option<f64>,
|
|
#[serde(rename = "loc_long")]
|
|
loc_long: Option<f64>,
|
|
#[serde(rename = "loc_city")]
|
|
loc_city: Option<String>,
|
|
#[serde(rename = "loc_state")]
|
|
loc_state: Option<String>,
|
|
#[serde(rename = "loc_state_name")]
|
|
loc_state_name: Option<String>,
|
|
#[serde(rename = "loc_country")]
|
|
loc_country: Option<String>,
|
|
#[serde(rename = "loc_postal_code")]
|
|
loc_postal_code: Option<String>,
|
|
}
|
|
|
|
/// Top-level Brave LLM Context API response: optional grounding (generic/poi/map) and optional sources map.
|
|
#[derive(Debug, Deserialize)]
|
|
struct BraveLlmContextResponse {
|
|
grounding: Option<Grounding>,
|
|
sources: Option<serde_json::Map<String, serde_json::Value>>,
|
|
}
|
|
|
|
/// 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<Vec<GenericEntry>>,
|
|
/// Point-of-interest data, sometimes present when local recall is enabled (e.g. via X-Loc-* headers or enable_local).
|
|
poi: Option<PoiMapEntry>,
|
|
/// Map/place results when local recall is enabled. Array of place entries with name, url, title, snippets.
|
|
map: Option<Vec<PoiMapEntry>>,
|
|
}
|
|
|
|
/// One URL's extracted content in `grounding.generic`: url, title, and text snippets.
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct GenericEntry {
|
|
url: Option<String>,
|
|
title: Option<String>,
|
|
snippets: Option<Vec<String>>,
|
|
}
|
|
|
|
/// Entry shape for `grounding.poi` (single object) and `grounding.map` (array). Present when local recall is active.
|
|
#[derive(Debug, Deserialize)]
|
|
struct PoiMapEntry {
|
|
name: Option<String>,
|
|
url: Option<String>,
|
|
title: Option<String>,
|
|
snippets: Option<Vec<String>>,
|
|
}
|
|
|
|
/// 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<String, String> {
|
|
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 <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<String, String> {
|
|
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<String, String> {
|
|
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<serde_json::Value> = 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<serde_json::Value> = 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<Vec<u8>, 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<String>); 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<String>); 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<String, serde_json::Value>,
|
|
) -> 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<String, serde_json::Value>,
|
|
) -> 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<String> {
|
|
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::<Vec<_>>()
|
|
.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<String, serde_json::Value> =
|
|
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<String, serde_json::Value> =
|
|
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<String, serde_json::Value> =
|
|
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<String, serde_json::Value> =
|
|
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<String, serde_json::Value> =
|
|
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<String, serde_json::Value> =
|
|
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"
|
|
);
|
|
}
|
|
}
|