From 82605ed1f6a17029176a17ce025093770d10458e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 4 Nov 2026 09:29:54 +0000 Subject: [PATCH 1/3] Run grounded Gemini search at low thinking by default Co-authored-by: Medulla --- .../src/provider/gemini_search_tests.rs | 2 +- crates/tinysearch/src/provider/mod.rs | 14 ++++++- crates/tinysearch/src/provider/mod_tests.rs | 39 ++++++++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/tinysearch/src/provider/gemini_search_tests.rs b/crates/tinysearch/src/provider/gemini_search_tests.rs index 81f0acb..4fd166d 100644 --- a/crates/tinysearch/src/provider/gemini_search_tests.rs +++ b/crates/tinysearch/src/provider/gemini_search_tests.rs @@ -64,7 +64,7 @@ fn search(arguments: Value) -> ExecuteToolRequest { async fn gemini_mock(server: &MockServer, value: Value, count: u64) { Mock::given(method("POST")).and(path(GEMINI_PATH)) .and(header("x-goog-api-key", "gemini-key")) - .and(body_json(json!({"contents":[{"parts":[{"text":"fixture query"}]}],"tools":[{"google_search":{}}]}))) + .and(body_json(json!({"contents":[{"parts":[{"text":"fixture query"}]}],"tools":[{"google_search":{}}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"low"}}}))) .respond_with(ResponseTemplate::new(200).set_body_json(value)).expect(count).mount(server).await; } diff --git a/crates/tinysearch/src/provider/mod.rs b/crates/tinysearch/src/provider/mod.rs index 71b6421..ae5073b 100644 --- a/crates/tinysearch/src/provider/mod.rs +++ b/crates/tinysearch/src/provider/mod.rs @@ -7,6 +7,12 @@ use reqwest::{Client, Method}; use serde_json::{Map, Value, json}; use std::{collections::BTreeMap, sync::Arc, time::Duration}; +/// The grounded-search model used unless the caller names one. +const DEFAULT_GEMINI_MODEL: &str = "gemini-3.8-flash"; +/// The default model's thinking level. Search is interactive, so it runs below +/// the model's own `medium`. Other models keep their defaults: `low` would +/// raise one that defaults to `minimal`, and older models refuse the field. +const DEFAULT_GEMINI_THINKING_LEVEL: &str = "low"; const MAX_RESULTS: usize = 20; const MAX_CITATIONS: usize = 40; const MAX_ANSWER_CHARS: usize = 12_000; @@ -164,7 +170,7 @@ impl BuiltinProvider { .arguments .get("model") .and_then(Value::as_str) - .unwrap_or("gemini-3.8-flash"); + .unwrap_or(DEFAULT_GEMINI_MODEL); if !model .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_') @@ -176,7 +182,11 @@ impl BuiltinProvider { } else { json!([{"google_search":{}}]) }; - let body = json!({"contents":[{"parts":[{"text":query}]}],"tools":tools}); + let mut body = json!({"contents":[{"parts":[{"text":query}]}],"tools":tools}); + if model == DEFAULT_GEMINI_MODEL { + body["generationConfig"] = + json!({"thinkingConfig":{"thinkingLevel":DEFAULT_GEMINI_THINKING_LEVEL}}); + } let (url, auth) = match config.route { ProviderRoute::Backend => ( backend_url( diff --git a/crates/tinysearch/src/provider/mod_tests.rs b/crates/tinysearch/src/provider/mod_tests.rs index d146f4d..6457442 100644 --- a/crates/tinysearch/src/provider/mod_tests.rs +++ b/crates/tinysearch/src/provider/mod_tests.rs @@ -693,7 +693,11 @@ async fn gemini_grounding_normalizes_supported_chunks_first() -> TestResult<()> let sent = server.await??; assert_eq!( sent_body(&sent)?, - json!({"contents":[{"parts":[{"text":"is rust fast"}]}],"tools":[{"googleSearch":{}}]}) + json!({ + "contents":[{"parts":[{"text":"is rust fast"}]}], + "tools":[{"googleSearch":{}}], + "generationConfig":{"thinkingConfig":{"thinkingLevel":"low"}} + }) ); assert_eq!(response.answer.as_deref(), Some("Rust is fast.")); assert_eq!( @@ -780,6 +784,39 @@ async fn direct_gemini_keeps_backend_headers_off_request() -> TestResult<()> { Ok(()) } +#[tokio::test] +async fn gemini_keeps_a_chosen_models_own_thinking_default() -> TestResult<()> { + // `low` would raise a model whose default is `minimal`, and older models + // refuse `thinkingLevel` outright, so only the default model carries it. + let (url, server) = mock(200, json!({"candidates":[]})).await?; + let provider = BuiltinProvider { + name: "gemini", + client: Client::new(), + }; + let config = ProviderConfig { + route: ProviderRoute::Direct, + credential: Some("google-key".into()), + base_url: Some(url), + ..ProviderConfig::default() + }; + provider + .run( + &config, + &backend("http://unused".into(), BackendAuthMode::ApiKey), + &request( + "gemini_agentic_search", + json!({"query":"question","model":"gemini-3.5-flash-lite"}), + ), + ) + .await?; + let sent = server.await??; + assert_eq!( + sent_body(&sent)?, + json!({"contents":[{"parts":[{"text":"question"}]}],"tools":[{"google_search":{}}]}) + ); + Ok(()) +} + #[tokio::test] async fn upstream_error_does_not_echo_sensitive_body() -> TestResult<()> { let (url, server) = mock(400, json!({"message":"secret query"})).await?; From b36c48dfa26a292f1b69484a87cf3c2d72b89211 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 4 Nov 2026 09:29:54 +0000 Subject: [PATCH 2/3] Default Exa search to the low-latency lite type Co-authored-by: Medulla --- .../tinysearch-bus/src/search/catalog/mod.rs | 2 +- .../src/search/catalog/mod_tests.rs | 15 ++++ crates/tinysearch/src/provider/direct/exa.rs | 14 +++- .../src/provider/direct/exa_tests.rs | 72 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 crates/tinysearch/src/provider/direct/exa_tests.rs diff --git a/crates/tinysearch-bus/src/search/catalog/mod.rs b/crates/tinysearch-bus/src/search/catalog/mod.rs index 8dd615b..37bac7c 100644 --- a/crates/tinysearch-bus/src/search/catalog/mod.rs +++ b/crates/tinysearch-bus/src/search/catalog/mod.rs @@ -127,7 +127,7 @@ fn direct_provider_specs() -> BTreeMap> { tool( "exa_search", "Search with Exa", - json!({"query":text,"max_results":{"type":"integer","minimum":1,"maximum":20},"type":{"type":"string","enum":["auto","instant","fast","deep-lite","deep","deep-reasoning"]},"category":text,"include_domains":urls(20),"exclude_domains":urls(20),"start_published_date":text,"end_published_date":text,"include_text":{"type":"boolean"},"include_highlights":{"type":"boolean"}}), + json!({"query":text,"max_results":{"type":"integer","minimum":1,"maximum":20},"type":{"type":"string","enum":["auto","lite","instant","fast","deep-lite","deep","deep-reasoning"]},"category":text,"include_domains":urls(20),"exclude_domains":urls(20),"start_published_date":text,"end_published_date":text,"include_text":{"type":"boolean"},"include_highlights":{"type":"boolean"}}), &["query"], ), tool( diff --git a/crates/tinysearch-bus/src/search/catalog/mod_tests.rs b/crates/tinysearch-bus/src/search/catalog/mod_tests.rs index 1b4fcd7..5823ef3 100644 --- a/crates/tinysearch-bus/src/search/catalog/mod_tests.rs +++ b/crates/tinysearch-bus/src/search/catalog/mod_tests.rs @@ -296,6 +296,21 @@ fn backend_route_narrows_exa_search_and_gemini_models() { ); } +#[test] +fn exa_search_offers_the_lite_type() { + let mut config = SearchConfig::default(); + config.providers.insert("exa".into(), keyed("key")); + let available = configured_provider_tools(&config, &provider_tool_specs()); + let exa_search = &available["exa"][0]; + assert_eq!(exa_search.name, "exa_search"); + let types = &exa_search.parameters["properties"]["type"]["enum"]; + assert!( + types + .as_array() + .is_some_and(|types| types.contains(&json!("lite"))) + ); +} + fn role_config() -> SearchConfig { let mut config = SearchConfig::default(); config.backend.credential = Some("session".into()); diff --git a/crates/tinysearch/src/provider/direct/exa.rs b/crates/tinysearch/src/provider/direct/exa.rs index 740154b..2880e0e 100644 --- a/crates/tinysearch/src/provider/direct/exa.rs +++ b/crates/tinysearch/src/provider/direct/exa.rs @@ -4,6 +4,10 @@ use reqwest::Method; use serde_json::{Value, json}; use std::time::Duration; +/// The search type sent unless the caller chose one: Exa's low-latency tier +/// for interactive use. Its own default, `auto`, trades speed for quality. +const DEFAULT_SEARCH_TYPE: &str = "lite"; + pub(super) fn prepare(request: &ExecuteToolRequest, key: &str) -> Result { let (path, body) = exa_body(request)?; Ok(( @@ -23,7 +27,11 @@ pub(super) fn exa_body(request: &ExecuteToolRequest) -> Result<(&'static str, Va let (path, mut body) = match request.name.as_str() { "exa_search" => ( "/search", - json!({"query":required_string(args,"query")?,"numResults":count(args,"max_results")}), + json!({ + "query":required_string(args,"query")?, + "numResults":count(args,"max_results"), + "type":DEFAULT_SEARCH_TYPE + }), ), "exa_find_similar" => ( "/findSimilar", @@ -88,3 +96,7 @@ pub(super) fn exa_body(request: &ExecuteToolRequest) -> Result<(&'static str, Va } Ok((path, body)) } + +#[cfg(test)] +#[path = "exa_tests.rs"] +mod tests; diff --git a/crates/tinysearch/src/provider/direct/exa_tests.rs b/crates/tinysearch/src/provider/direct/exa_tests.rs new file mode 100644 index 0000000..76e286e --- /dev/null +++ b/crates/tinysearch/src/provider/direct/exa_tests.rs @@ -0,0 +1,72 @@ +use super::*; + +fn search(arguments: Value) -> ExecuteToolRequest { + ExecuteToolRequest { + name: "exa_search".into(), + arguments, + } +} + +#[test] +fn search_defaults_to_the_low_latency_type() -> Result<()> { + let (path, body) = exa_body(&search(json!({"query":"rust"})))?; + assert_eq!(path, "/search"); + assert_eq!(body["type"], "lite"); + Ok(()) +} + +#[test] +fn search_keeps_a_type_the_caller_chose() -> Result<()> { + let (_, body) = exa_body(&search(json!({"query":"rust","type":"deep"})))?; + assert_eq!(body["type"], "deep"); + Ok(()) +} + +#[test] +fn default_search_sends_only_what_the_lite_type_accepts() -> Result<()> { + // Exa rejects a `lite` request that sets anything outside its short list. + let (_, body) = exa_body(&search(json!({ + "query":"rust", + "max_results":3, + "category":"news", + "include_domains":["example.org"], + "exclude_domains":["spam.org"], + "start_published_date":"2026-01-01", + "end_published_date":"2026-02-01", + "include_text":true, + "include_highlights":true + })))?; + let mut sent: Vec<&str> = body + .as_object() + .ok_or(Error::InvalidArguments)? + .keys() + .map(String::as_str) + .collect(); + sent.sort_unstable(); + assert_eq!( + sent, + [ + "category", + "contents", + "endPublishedDate", + "excludeDomains", + "includeDomains", + "numResults", + "query", + "startPublishedDate", + "type" + ] + ); + assert_eq!(body["contents"], json!({"text":true,"highlights":true})); + Ok(()) +} + +#[test] +fn only_search_takes_a_type() -> Result<()> { + let (_, body) = exa_body(&ExecuteToolRequest { + name: "exa_find_similar".into(), + arguments: json!({"url":"https://example.org"}), + })?; + assert!(body.get("type").is_none()); + Ok(()) +} From 48d898b98be0092544c7a8c77903540f45437eac Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 4 Nov 2026 09:29:54 +0000 Subject: [PATCH 3/3] Document the fast search defaults Co-authored-by: Medulla --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8bd28be..65c029d 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,17 @@ and `/answer` with `x-api-key`. On the backend route they call `/agent-integrations/exa/{search,findSimilar,contents,answer}`. The backend's search takes only `{objective, searchQueries}`, so the backend `exa_search` schema accepts only `query`, sent as both; the other routes forward Exa's own -request bodies. +request bodies. Direct `exa_search` sends `type: "lite"`, Exa's low-latency +tier, unless the caller chose a type. `lite` reads Exa's index without fetching +pages, so results with no cached copy are left out when text or highlights are +requested. Gemini's `gemini_agentic_search` calls `generateContent` with Google Search grounding: directly with `x-goog-api-key`, or through `/agent-integrations/gemini/models/{model}/generate-content`, whose schema limits `model` to the backend's allowlist (default `gemini-3.8-flash`). The +default model runs with `thinkingLevel: low`, on both routes, to keep grounded +calls fast; a different `model` keeps that model's own thinking default. The answer joins the candidate's text parts, and citations list the grounding chunks that `groundingSupports` reference first, then the rest. For the Search role, the same grounded call returns deduplicated HTTP(S)