Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysearch-bus/src/search/catalog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ fn direct_provider_specs() -> BTreeMap<String, Vec<ToolSpec>> {
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(
Expand Down
15 changes: 15 additions & 0 deletions crates/tinysearch-bus/src/search/catalog/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
14 changes: 13 additions & 1 deletion crates/tinysearch/src/provider/direct/exa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Prepared> {
let (path, body) = exa_body(request)?;
Ok((
Expand All @@ -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",
Expand Down Expand Up @@ -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;
72 changes: 72 additions & 0 deletions crates/tinysearch/src/provider/direct/exa_tests.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
2 changes: 1 addition & 1 deletion crates/tinysearch/src/provider/gemini_search_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
14 changes: 12 additions & 2 deletions crates/tinysearch/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 == '_')
Expand All @@ -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(
Expand Down
39 changes: 38 additions & 1 deletion crates/tinysearch/src/provider/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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?;
Expand Down
Loading