Skip to content

feat(tool): add Tavily as a selectable web_search provider (#327) - #329

Open
Zheng-Lu wants to merge 1 commit into
vllm-project:mainfrom
Zheng-Lu:feat/327-tavily-provider
Open

Zheng-Lu wants to merge 1 commit into
vllm-project:mainfrom
Zheng-Lu:feat/327-tavily-provider

Conversation

@Zheng-Lu

@Zheng-Lu Zheng-Lu commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Closes #327 (Part of #291).

Summary

Adds Tavily as the third backend for the gateway-owned web_search tool (Phase 3 of #291, closes #327), following the typed provider contract from #293/#294 and the Brave pattern from #324.

  • WebSearchProviderKind::Tavily (config.rs): config_name = "tavily", TAVILY_API_KEY, default endpoint https://api.tavily.com, no provider-specific concurrency ceiling (Tavily plans are metered per minute, so it inherits the gateway limit). ALL, FromStr, and the "expected one of" message list all three providers. No public signature changes: the enum and WebSearchProviderConfig are #[non_exhaustive], and WebSearchHandler::from_config gains a match arm. WebSearchProviderKind::ALL is now a &'static [Self] slice so adding a provider no longer changes its type.
  • tool/web_search/tavily.rs (new, 391 production lines): TavilySearchProvider posts a typed TavilySearchBody to POST {base_url}/search with Content-Type: application/json, Accept: application/json, and Authorization: Bearer <key>. The key is never placed in the body and Accept-Encoding is never sent (core reqwest has no gzip). Request shaping:
    • count / search_context_sizemax_results, clamped to 20 with a debug log;
    • freshness day/week/month/year → time_range; YYYY-MM-DDtoYYYY-MM-DDstart_date / end_date widened by one day on each side (checked pred_opt / succ_opt), because the gateway's range is inclusive while Tavily documents its bounds as "after" / "before";
    • allowed_domains / blocked_domains (request config wins) or the model's include_domains / exclude_domains → native include_domains / exclude_domains, and re-applied client-side through DomainFilter as defense in depth; the shared include+exclude/boost conflict rule is preserved;
    • language keeps Tavily's documented compound tags lowercased (zh-CNzh-cn) and otherwise reduces to the primary subtag (en-GBen); safesearch → boolean safe_search (off → omitted);
    • include_published_date: true so page_age is populated for the general topic;
    • country (Tavily wants full country names, not ISO codes) and the You.com-specific livecrawl*, crawl_timeout, boost_domains are dropped with one debug log.
    • One topic: "general" request per query: every hit lands in results.web, results.news stays empty. A second news request per query would double credit spend; this is documented.
  • Response mapping: forward-compatible #[serde(default)] structs read results[] { title, url, content, published_date } plus response_time and request_id; contentdescription, published_datepage_age. metadata[] carries "provider": "tavily", request_id as search_uuid, and response_time as latency (existing skip_serializing_if rules, You.com output unchanged).
  • Failures (credential-free): 401/403 name TAVILY_API_KEY without reading the body; 429 is not retried and surfaces Retry-After verbatim (or says none was provided); Tavily's non-standard 432/433 plan-limit statuses are reported without retry; anything else reports status + size-bounded body.
  • mod.rs size: adding the module pushed mod.rs to 507 production lines, so the shared null_as_default and read_response_limited helpers moved to provider.rs (where the provider contract lives). Both were and remain crate-private, so no public API changed. mod.rs is now 476 lines; no other file changed behavior.
  • Server plumbing: web_search_config.rs / config_file.rs are provider-generic already; only doc comments and tests changed.
  • Docs: README.md (feature bullet, quick start, config.toml comments, provider table, Tavily section), docs/deploying/README.md, docs/deploying/kubernetes.md, ARCHITECTURE.md, CHANGELOG.md.

No new dependencies; the request body is serialized with the existing serde_json rather than enabling reqwest's json feature.

Deliberately kept in parity with brave.rs / you.rs rather than changed here (candidates for a cross-provider follow-up): generic non-2xx failures echo the size-bounded upstream body (401/403/429/432/433 never do), Retry-After is whitespace-trimmed, and the caller-supplied reqwest::Client contract is gateway-wide.

Test Plan

  • cargo fmt -- --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test — 1606 passed, 0 failed (You.com and Brave suites untouched and green)
  • pre-commit run --all-files — all hooks pass, including the Rust production file-size policy (mod.rs 476, tavily.rs 391, provider.rs 144)
  • New hermetic integration suite crates/agentic-server-core/tests/web_search_tavily_test.rs (12 tests, Axum mock on 127.0.0.1:0, nothing reaches the network):
    • 200 search: exact JSON body (clamped max_results, time_range, language, safe_search, exclude_domains, no country/livecrawl/api_key), Bearer auth, Content-Type, no Accept-Encoding, byte-exact model-facing output with "provider":"tavily" + search_uuid + latency, web_search_call public sources
    • date-range freshness → start_date/end_date widened by one day each side, no max_results when count unset
    • empty results{"web":[],"news":[]} with no invented sources
    • domain filters: request-level allowlist wins and is forwarded natively; off-list results from a mock that ignores the filter are dropped client-side; exclude_domains forwarded and enforced
    • 401/403: names TAVILY_API_KEY, never echoes the key or the upstream body
    • 429: Retry-After integer and HTTP-date surfaced verbatim, absence reported, exactly one request (no retry)
    • 432/433: plan-limit message, no retry
    • 500: status + body; non-JSON body → "invalid JSON"
    • concurrency: 5 batched queries overlap by default (gateway ceiling 5); max_concurrent_queries = 1 serializes
  • Inline unit tests in tavily.rs (13 tests) cover the private request/response types: payload rendering and omission of unset fields, clamping, freshness mapping including month/year boundary widening, language (zh-CNzh-cn, zh-TWzh, en-GBen) and safe-search reduction, ignored arguments, config-over-args precedence, conflict rejection, envelope tolerance ({}, nulls, unknown keys), metadata serialization, defense-in-depth filtering
  • config.rs, web_search_config.rs, config_file.rs tests extended for the third provider (labels, parsing, env/file resolution, api_key_env override, provider switching without pinned credentials, TOML round trip)

…ect#327)

Add Tavily as the third backend for the gateway-owned web_search tool
(Phase 3 of vllm-project#291). Each query is one typed POST /search with a bearer
token; domain filters are forwarded natively and re-checked client-side,
count is clamped to 20, freshness maps to time_range or a date range
widened by one day on each side because Tavily's bounds are exclusive,
language keeps Tavily's documented compound tags and otherwise reduces
to the primary subtag, and 401/403/429/432/433 fail the web_search_call
without retry or credential leakage. Move the shared response helpers
into provider.rs to keep mod.rs within the file-size policy, and make
WebSearchProviderKind::ALL a static slice so new providers do not
change its type.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add Tavily as a selectable web_search provider

1 participant