feat(search): Tavily autodetects from TAVILY_API_KEY, Firecrawl stays default (#6298 program, web-search slice A)

Per codewhale-ops/WEB-SEARCH-20260917.md slice A: when TAVILY_API_KEY is
present and no provider is pinned, resolution returns Tavily with source
"tavily key"; otherwise Firecrawl/Default is unchanged. Autodetect is
runtime-only — nothing is written to [search] provider and the env key is
never merged into SearchConfig.api_key (read at preflight/request time).

- SearchProviderSource::TavilyKey (as_str "tavily key") + looks_like_tavily_key
  / tavily_env_key / tavily_key_from in config/search.rs.
- run_tavily_search prefers TAVILY_API_KEY, falls back to a non-empty generic
  key; preflight accepts generic OR env key. Deliberate: preflight does NOT
  prefix-gate the generic key (an explicit non-tvly- pin must keep working;
  only autodetect prefix-gates), which is the doc's own risk row made real.
- Doctor line/JSON, /config display, and GET /v1/config source all use the one
  "tavily key" token; POST persist refuses to turn Default/TavilyKey into a
  disk pin. TAVILY_API_KEY added to the workspace-dotenv credential allowlist.
- Docs: CONFIGURATION.md + zh_hans Tavily paragraph; config.example.toml
  autodetect/env lines (SearXNG block untouched — slice B owns it).

Evidence (local, macOS arm64): cargo check -p codewhale-tui --all-targets
exit 0 (1m43s); required filter "12 passed; 0 failed"; search_provider filter
"21 passed; 0 failed" (includes the runtime_api GET/persist-skip tests) —
both re-run by the parent. cargo fmt clean.
This commit is contained in:
CodeWhale Bot committed 2026-09-17 06:30:21 -07:00
1 parent a1840d4594
commit ff695506e0
11 files changed
+674 -42

No files matched your search

+5
View File
@@ -1027,6 +1027,8 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
# # duckduckgo: HTML scrape with Bing fallback
# # bing: HTML scrape, no API key
# # tavily: https://tavily.com — AI search, needs api_key
# # TAVILY_API_KEY set + provider unset auto-selects tavily
# # at runtime; it is never written to config.toml
# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key
# # metaso: https://metaso.cn — 秘塔AI搜索,需 api_key
# # 设置 METASO_API_KEY 或 [search] api_key
@@ -1060,6 +1062,9 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
# VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY → volcengine key fallback
# SOFYA_API_KEY → sofya key fallback
# SERPLY_API_KEY → serply key fallback
# TAVILY_API_KEY → tavily autodetect when provider is unset (explicit
# provider = "..." or CODEWHALE_SEARCH_PROVIDER still
# wins; the autodetect is never persisted)
# ─────────────────────────────────────────────────────────────────────────────────
# Network Policy (#135)
@@ -1163,6 +1163,10 @@ fn search_provider_display(config: &Config, locale: codewhale_localization::Loca
.to_string(),
SearchProviderSource::Config => "config.toml".to_string(),
SearchProviderSource::EnvOverride => "CODEWHALE_SEARCH_PROVIDER".to_string(),
// Same token doctor prints: the signal is a Tavily key, not a disk
// pin, so never name `TAVILY_API_KEY` (the winner may have been a
// generic `tvly-` `[search] api_key`).
SearchProviderSource::TavilyKey => "tavily key".to_string(),
};
tr(locale, MessageId::ConfigCommandSource)
.replace("{value}", resolved.provider.as_str())
@@ -3363,6 +3367,7 @@ mod tests {
EnvVarGuard::remove("PTYXIS_VERSION"),
EnvVarGuard::remove("CODEWHALE_SEARCH_PROVIDER"),
EnvVarGuard::remove("DEEPSEEK_SEARCH_PROVIDER"),
EnvVarGuard::remove("TAVILY_API_KEY"),
];
Self {
_vars: vars,
@@ -5054,6 +5059,33 @@ completion_sound = "bell"
);
}
#[test]
fn config_command_shows_autodetected_tavily_key_source() {
let temp_root = tempfile::tempdir().expect("isolated config dir");
let _guard = EnvGuard::new(temp_root.path());
let config_path = temp_root.path().join("custom-config.toml");
fs::write(
&config_path,
r#"
[search]
api_key = "tvly-autodetected"
"#,
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(config_path);
let search = config_command(&mut app, Some("search.provider"));
assert!(!search.is_error, "{:?}", search.message);
let message = search.message.expect("search provider display");
assert_eq!(message, "search.provider = tavily (source: tavily key)");
assert!(
!message.contains("TAVILY_API_KEY"),
"a generic `tvly-` key must not be reported as the env var: {message}"
);
}
#[test]
fn config_command_sets_search_prompt_suggestion_and_notifications() {
let temp_root = tempfile::tempdir().expect("isolated config dir");
+15
View File
@@ -4704,6 +4704,21 @@ impl Config {
};
}
// Tavily autodetect: a dedicated `TAVILY_API_KEY`, or a generic
// `[search] api_key` in the `tvly-` family. Runtime-only — never write
// `[search] provider` from here, and never merge the env key into
// `search.api_key`.
let generic_key = self
.search
.as_ref()
.and_then(|search| search.api_key.as_deref());
if tavily_key_from(generic_key).is_some() {
return SearchProviderResolution {
provider: SearchProvider::Tavily,
source: SearchProviderSource::TavilyKey,
};
}
SearchProviderResolution {
provider: SearchProvider::default(),
source: SearchProviderSource::Default,
+54
View File
@@ -115,19 +115,67 @@ pub enum SearchProviderSource {
Default,
Config,
EnvOverride,
/// Autodetected from a Tavily key signal: `TAVILY_API_KEY`, or a generic
/// `[search] api_key` / `CODEWHALE_SEARCH_API_KEY` value in the `tvly-`
/// family. Runtime-only — resolution never writes `[search] provider`.
TavilyKey,
}
impl SearchProviderSource {
/// One honest source token for doctor, `/config`, and the runtime GET
/// route. `tavily key` names where the signal came from without claiming a
/// disk pin or naming `TAVILY_API_KEY` when the winner was a `tvly-`
/// generic key.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Config => "config",
Self::EnvOverride => "env override",
Self::TavilyKey => "tavily key",
}
}
}
/// Tavily issues keys in the `tvly-` family; the Tavily error copy already
/// says so. Applied to **generic** keys only — a dedicated `TAVILY_API_KEY`
/// is honored as-is, however it is shaped.
#[must_use]
pub fn looks_like_tavily_key(value: &str) -> bool {
value.trim().starts_with("tvly-")
}
/// `TAVILY_API_KEY`, read at request time. Deliberately never merged into
/// [`SearchConfig::api_key`]: that generic slot is shared by every provider,
/// so merging would hand a Tavily key to Firecrawl (or the reverse) the moment
/// the operator pins a different provider.
#[must_use]
pub fn tavily_env_key() -> Option<String> {
std::env::var("TAVILY_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// The key a Tavily request should send.
///
/// Dedicated env wins over the generic `[search] api_key` so resolution and
/// the request agree on one key: `[search] api_key` is shared, and a
/// Firecrawl `fc-` / sentinel generic value must never be POSTed to
/// `api.tavily.com`. The generic fallback is prefix-gated, which is what
/// stops `CODEWHALE_SEARCH_API_KEY=doctor-offline-search-sentinel` from
/// autodetecting Tavily.
#[must_use]
pub fn tavily_key_from(search_api_key: Option<&str>) -> Option<String> {
if let Some(env_key) = tavily_env_key() {
return Some(env_key);
}
search_api_key
.map(str::trim)
.filter(|value| !value.is_empty() && looks_like_tavily_key(value))
.map(str::to_string)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchProviderResolution {
pub provider: SearchProvider,
@@ -150,6 +198,12 @@ pub struct SearchConfig {
/// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var.
/// Serply also falls back to the `SERPLY_API_KEY` env var.
/// Volcengine also falls back to `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` env vars.
///
/// This slot is shared across providers. `TAVILY_API_KEY` is **not**
/// merged into it — Tavily reads its dedicated env at request time
/// ([`tavily_key_from`]) so pinning another provider never forwards a
/// Tavily key, and a `tvly-` value here can autodetect Tavily without a
/// disk write.
#[serde(default)]
pub api_key: Option<String>,
}
+158 -32
View File
@@ -1692,6 +1692,31 @@ fn window_title_config_parses_and_overlays() {
assert_eq!(merged.title.as_deref(), Some("base-title"));
}
/// Run `body` with the three Tavily-resolution signals set exactly as given,
/// restoring the ambient values afterwards. A `Default` pin (and the
/// Firecrawl China-switch hint) means all three are absent, not just the
/// legacy `DEEPSEEK_SEARCH_PROVIDER` alias.
fn with_search_resolution_env<R>(set: &[(&str, &str)], body: impl FnOnce() -> R) -> R {
let _guard = lock_test_env();
let keys = [
"CODEWHALE_SEARCH_PROVIDER",
"DEEPSEEK_SEARCH_PROVIDER",
"TAVILY_API_KEY",
];
let previous: Vec<Option<OsString>> = keys.iter().map(|key| env::var_os(key)).collect();
for key in keys {
unsafe { env::remove_var(key) };
}
for (key, value) in set {
unsafe { env::set_var(key, value) };
}
let output = body();
for (key, value) in keys.iter().zip(previous) {
unsafe { EnvGuard::restore_var(key, value) };
}
output
}
#[test]
fn search_provider_scenario() {
// Scenario consolidation of: search_provider_defaults_to_firecrawl, search_provider_resolution_reports_default_source, search_provider_resolution_reports_config_source, search_provider_resolution_reports_env_override_source, search_provider_env_override_accepts_baidu, search_provider_resolution_ignores_invalid_env_override
@@ -1705,22 +1730,121 @@ fn search_provider_scenario() {
assert_eq!(SearchProvider::Firecrawl.as_str(), "firecrawl");
}
// from search_provider_resolution_reports_default_source
{
let _guard = lock_test_env();
let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
unsafe { env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
// (empty config, no env)
with_search_resolution_env(&[], || {
let config = Config::default();
let resolution = config.search_provider_resolution();
let resolution = Config::default().search_provider_resolution();
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
assert_eq!(resolution.provider, SearchProvider::Firecrawl);
assert_eq!(resolution.source, SearchProviderSource::Default);
}
// Autodetect never writes a provider into the config view.
assert_eq!(
config.search.as_ref().and_then(|search| search.provider),
None
);
});
// `TAVILY_API_KEY=tvly-test`, provider unset
with_search_resolution_env(&[("TAVILY_API_KEY", "tvly-test")], || {
let config = Config::default();
let resolution = config.search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Tavily);
assert_eq!(resolution.source, SearchProviderSource::TavilyKey);
assert_eq!(resolution.source.as_str(), "tavily key");
assert_eq!(resolution.provider.as_str(), "tavily");
// Autodetect is runtime-only: `config.search.provider` stays unset.
assert_eq!(
config.search.as_ref().and_then(|search| search.provider),
None
);
});
// `TAVILY_API_KEY=not-a-tvly-prefix`, provider unset — a dedicated env key
// is never prefix-checked.
with_search_resolution_env(&[("TAVILY_API_KEY", "not-a-tvly-prefix")], || {
let resolution = Config::default().search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Tavily);
assert_eq!(resolution.source, SearchProviderSource::TavilyKey);
});
// `[search] api_key = "tvly-test"`, provider unset — also the shape left by
// `CODEWHALE_SEARCH_API_KEY=tvly-test` after `apply_env_overrides`.
with_search_resolution_env(&[], || {
let config: Config = toml::from_str(
r#"
[search]
api_key = "tvly-test"
"#,
)
.expect("search config");
let resolution = config.search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Tavily);
assert_eq!(resolution.source, SearchProviderSource::TavilyKey);
assert_eq!(
config.search.as_ref().and_then(|search| search.provider),
None
);
});
// A non-`tvly-` generic key never autodetects. Covers
// `CODEWHALE_SEARCH_API_KEY=doctor-offline-search-sentinel` and any
// Firecrawl `fc-` generic value.
with_search_resolution_env(&[], || {
let config: Config = toml::from_str(
r#"
[search]
api_key = "doctor-offline-search-sentinel"
"#,
)
.expect("search config");
let resolution = config.search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Firecrawl);
assert_eq!(resolution.source, SearchProviderSource::Default);
});
// Dedicated provider keys that are not the Tavily signal leave the
// Firecrawl default in place.
with_search_resolution_env(
&[
("FIRECRAWL_API_KEY", "fc-test"),
("SOFYA_API_KEY", "ay_live_test"),
],
|| {
let resolution = Config::default().search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Firecrawl);
assert_eq!(resolution.source, SearchProviderSource::Default);
},
);
// Explicit Firecrawl wins over a Tavily key.
with_search_resolution_env(&[("TAVILY_API_KEY", "tvly-test")], || {
let config: Config = toml::from_str(
r#"
[search]
provider = "firecrawl"
"#,
)
.expect("search config");
let resolution = config.search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Firecrawl);
assert_eq!(resolution.source, SearchProviderSource::Config);
});
// An env override outranks both the config pin and the Tavily signal.
with_search_resolution_env(
&[
("CODEWHALE_SEARCH_PROVIDER", "firecrawl"),
("TAVILY_API_KEY", "tvly-test"),
],
|| {
let resolution = Config::default().search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Firecrawl);
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
},
);
// from search_provider_resolution_reports_config_source
{
let _guard = lock_test_env();
let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
unsafe { env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
with_search_resolution_env(&[], || {
let config: Config = toml::from_str(
r#"
[search]
@@ -1731,15 +1855,11 @@ fn search_provider_scenario() {
let resolution = config.search_provider_resolution();
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
assert_eq!(resolution.provider, SearchProvider::Tavily);
assert_eq!(resolution.source, SearchProviderSource::Config);
}
});
// from search_provider_resolution_reports_env_override_source
{
let _guard = lock_test_env();
let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "bocha") };
with_search_resolution_env(&[("CODEWHALE_SEARCH_PROVIDER", "bocha")], || {
let config: Config = toml::from_str(
r#"
[search]
@@ -1750,15 +1870,26 @@ fn search_provider_scenario() {
let resolution = config.search_provider_resolution();
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
assert_eq!(resolution.provider, SearchProvider::Bocha);
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
}
});
// The legacy alias still resolves the same way.
with_search_resolution_env(&[("DEEPSEEK_SEARCH_PROVIDER", "bocha")], || {
let config: Config = toml::from_str(
r#"
[search]
provider = "duckduckgo"
"#,
)
.expect("search config");
let resolution = config.search_provider_resolution();
assert_eq!(resolution.provider, SearchProvider::Bocha);
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
});
// from search_provider_env_override_accepts_baidu
{
let _guard = lock_test_env();
let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "baidu") };
with_search_resolution_env(&[("CODEWHALE_SEARCH_PROVIDER", "baidu")], || {
let config: Config = toml::from_str(
r#"
[search]
@@ -1769,15 +1900,11 @@ fn search_provider_scenario() {
let resolution = config.search_provider_resolution();
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
assert_eq!(resolution.provider, SearchProvider::Baidu);
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
}
});
// from search_provider_resolution_ignores_invalid_env_override
{
let _guard = lock_test_env();
let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "not-a-provider") };
with_search_resolution_env(&[("CODEWHALE_SEARCH_PROVIDER", "not-a-provider")], || {
let config: Config = toml::from_str(
r#"
[search]
@@ -1788,10 +1915,9 @@ fn search_provider_scenario() {
let resolution = config.search_provider_resolution();
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
assert_eq!(resolution.provider, SearchProvider::Tavily);
assert_eq!(resolution.source, SearchProviderSource::Config);
}
});
}
#[test]
+146 -3
View File
@@ -2860,6 +2860,7 @@ fn is_workspace_dotenv_credential_key(key: &str) -> bool {
| "SERPLY_API_KEY"
| "METASO_API_KEY"
| "BAIDU_SEARCH_API_KEY"
| "TAVILY_API_KEY"
| "DEEPSEEK_SANDBOX_API_KEY"
)
}
@@ -7534,15 +7535,43 @@ fn doctor_search_provider_line(config: &Config) -> String {
} else {
""
};
// Missing-key is stdout-only (never JSON) and only applies when the
// operator pinned Tavily: autodetect (`tavily key`) cannot reach this line
// without a key signal, so it never reports a missing key.
let missing_key = if search_provider.provider == crate::config::SearchProvider::Tavily
&& matches!(
search_provider.source,
crate::config::SearchProviderSource::Config
| crate::config::SearchProviderSource::EnvOverride
)
&& !search_provider_has_tavily_key(config)
{
"; missing TAVILY_API_KEY or [search] api_key"
} else {
""
};
format!(
"search_provider: {} (source: {}{})",
"search_provider: {} (source: {}{}){}",
search_provider.provider.as_str(),
search_provider.source.as_str(),
switch_hint
switch_hint,
missing_key
)
}
/// Whether *any* Tavily key is reachable: the dedicated env var, or a
/// non-empty generic `[search] api_key`. Deliberately not prefix-gated — an
/// explicit `provider = "tavily"` accepts any non-empty generic key.
fn search_provider_has_tavily_key(config: &Config) -> bool {
crate::config::tavily_env_key().is_some()
|| config
.search
.as_ref()
.and_then(|search| search.api_key.as_deref())
.is_some_and(|key| !key.trim().is_empty())
}
fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
use serde_json::json;
@@ -14637,19 +14666,133 @@ mod doctor_endpoint_tests {
#[test]
fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
let _guard = crate::test_support::lock_test_env();
// A Default pin means all three Tavily-resolution signals are absent.
let prev_code = std::env::var_os("CODEWHALE_SEARCH_PROVIDER");
let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
let prev_tavily = std::env::var_os("TAVILY_API_KEY");
unsafe {
std::env::remove_var("CODEWHALE_SEARCH_PROVIDER");
std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER");
std::env::remove_var("TAVILY_API_KEY");
}
let line = doctor_search_provider_line(&Config::default());
match prev_code {
Some(value) => unsafe { std::env::set_var("CODEWHALE_SEARCH_PROVIDER", value) },
None => unsafe { std::env::remove_var("CODEWHALE_SEARCH_PROVIDER") },
}
match prev {
Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
}
match prev_tavily {
Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
}
assert!(line.contains("search_provider: firecrawl"));
assert!(line.contains("source: default"));
assert!(line.contains("[search] provider"));
assert!(line.contains("provider = \"baidu\""));
assert!(!line.contains("missing"), "got `{line}`");
}
#[test]
fn doctor_search_provider_line_reports_autodetected_tavily_key_without_missing_key() {
let _guard = crate::test_support::lock_test_env();
let prev_code = std::env::var_os("CODEWHALE_SEARCH_PROVIDER");
let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
let prev_tavily = std::env::var_os("TAVILY_API_KEY");
unsafe {
std::env::remove_var("CODEWHALE_SEARCH_PROVIDER");
std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER");
std::env::set_var("TAVILY_API_KEY", "tvly-test-doctor");
}
let config = Config::default();
let line = doctor_search_provider_line(&config);
let report = doctor_search_provider_json(&config);
match prev_code {
Some(value) => unsafe { std::env::set_var("CODEWHALE_SEARCH_PROVIDER", value) },
None => unsafe { std::env::remove_var("CODEWHALE_SEARCH_PROVIDER") },
}
match prev {
Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
}
match prev_tavily {
Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
}
assert_eq!(line, "search_provider: tavily (source: tavily key)");
assert_eq!(report["provider"], "tavily");
assert_eq!(report["source"], "tavily key");
assert!(
report.get("missing_key").is_none(),
"missing-key is stdout-only: {report}"
);
// Autodetect is runtime-only; nothing was written to the config view.
assert_eq!(
config.search.as_ref().and_then(|search| search.provider),
None
);
}
#[test]
fn doctor_search_provider_line_reports_explicit_tavily_missing_key() {
let _guard = crate::test_support::lock_test_env();
let prev_code = std::env::var_os("CODEWHALE_SEARCH_PROVIDER");
let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
let prev_tavily = std::env::var_os("TAVILY_API_KEY");
unsafe {
std::env::remove_var("CODEWHALE_SEARCH_PROVIDER");
std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER");
std::env::remove_var("TAVILY_API_KEY");
}
let config = Config {
search: Some(crate::config::SearchConfig {
provider: Some(crate::config::SearchProvider::Tavily),
base_url: None,
api_key: None,
}),
..Default::default()
};
let line = doctor_search_provider_line(&config);
// The env-override arm of the same rule.
unsafe { std::env::set_var("CODEWHALE_SEARCH_PROVIDER", "tavily") };
let env_line = doctor_search_provider_line(&Config::default());
match prev_code {
Some(value) => unsafe { std::env::set_var("CODEWHALE_SEARCH_PROVIDER", value) },
None => unsafe { std::env::remove_var("CODEWHALE_SEARCH_PROVIDER") },
}
match prev {
Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
}
match prev_tavily {
Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
}
assert_eq!(
line,
"search_provider: tavily (source: config); missing TAVILY_API_KEY or [search] api_key"
);
assert_eq!(
env_line,
"search_provider: tavily (source: env override); missing TAVILY_API_KEY or [search] api_key"
);
// Missing-key is stdout-only.
assert!(
doctor_search_provider_json(&config)
.get("missing_key")
.is_none()
);
}
#[test]
+36
View File
@@ -7496,6 +7496,9 @@ struct GuiConfigResponse {
strict_tool_mode: bool,
memory_enabled: bool,
search_provider: String,
/// How `search_provider` was chosen: `default` / `config` /
/// `env override` / `tavily key`. Runtime-only — never persisted.
search_provider_source: String,
prompt_suggestion: bool,
/// Effective device settings, using the same leaf vocabulary as CLI/TUI.
notifications: std::collections::BTreeMap<String, String>,
@@ -7609,6 +7612,11 @@ async fn get_config(
strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
memory_enabled: config.memory_enabled(),
search_provider: config.search_provider().as_str().to_string(),
search_provider_source: config
.search_provider_resolution()
.source
.as_str()
.to_string(),
prompt_suggestion: config.prompt_suggestion_enabled(),
notifications: codewhale_config::notifications::NotificationSetting::ALL
.into_iter()
@@ -7861,6 +7869,34 @@ async fn set_config(
}
"search_provider" => {
let normalized = value.to_lowercase();
// GET returns the *resolved* provider. A settings save that
// round-trips that value must not turn autodetect (or the
// Firecrawl default) into a disk pin — `provider = "firecrawl"`
// would flip the source to `config` and permanently block a
// later Tavily key. A POST that differs from the resolved
// provider is an explicit change and still persists.
let resolution = state.config.read().search_provider_resolution();
let posted = crate::config::SearchProvider::parse(&normalized);
if posted == Some(resolution.provider)
&& matches!(
resolution.source,
crate::config::SearchProviderSource::Default
| crate::config::SearchProviderSource::TavilyKey
)
{
return Ok(Json(SetConfigResponse {
key,
value,
message: format!(
"Config not persisted: '{}' is the resolved {} (source: {}), not a pin. Set a different provider, or pin it in config.toml.",
normalized,
resolution.provider.as_str(),
resolution.source.as_str()
),
persisted: false,
requires_reload: true,
}));
}
config_persistence::persist_table_string_key(
config_path,
"search",
+127
View File
@@ -9053,6 +9053,133 @@ async fn set_config_with_config_path_writes_to_specified_file() -> Result<()> {
Ok(())
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn get_config_search_provider_reports_runtime_source() -> Result<()> {
let _lock = lock_test_env();
let _code = EnvVarGuard::remove("CODEWHALE_SEARCH_PROVIDER");
let _legacy = EnvVarGuard::remove("DEEPSEEK_SEARCH_PROVIDER");
let _tavily = EnvVarGuard::remove("TAVILY_API_KEY");
let root = std::env::temp_dir().join(format!("codewhale-search-source-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("config.toml");
fs::write(&config_file, "[search]\napi_key = \"tvly-autodetected\"\n")?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let body: serde_json::Value = client
.get(format!("http://{addr}/v1/config"))
.send()
.await
.expect("GET /v1/config should not fail at transport level")
.json()
.await
.expect("GET /v1/config should be JSON");
handle.abort();
assert_eq!(body["search_provider"], "tavily", "body: {body}");
assert_eq!(
body["search_provider_source"], "tavily key",
"GET must publish the same source token doctor prints: {body}"
);
assert!(
body.get("missing_key").is_none(),
"missing-key is stdout-only: {body}"
);
Ok(())
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn set_config_search_provider_persist_skips_firecrawl_default_round_trip() -> Result<()> {
let _lock = lock_test_env();
let _code = EnvVarGuard::remove("CODEWHALE_SEARCH_PROVIDER");
let _legacy = EnvVarGuard::remove("DEEPSEEK_SEARCH_PROVIDER");
let _tavily = EnvVarGuard::remove("TAVILY_API_KEY");
let root = std::env::temp_dir().join(format!("codewhale-search-persist-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("config.toml");
fs::write(&config_file, "# initial\n")?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// GET returns `firecrawl`; saving it back must NOT write a pin, because
// `provider = "firecrawl"` would flip source Default -> Config and then
// block a later Tavily key.
let (status, body) =
post_set_config(&client, &addr, "search_provider", "firecrawl", true).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
assert_eq!(body["persisted"], false, "body: {body}");
let contents = fs::read_to_string(&config_file)?;
assert!(
!contents.contains("provider"),
"round-tripping the resolved default must not pin it: {contents}"
);
// An explicit *change* still persists.
let (status, body) = post_set_config(&client, &addr, "search_provider", "bocha", true).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
assert_eq!(body["persisted"], true, "body: {body}");
let contents = fs::read_to_string(&config_file)?;
assert!(
contents.contains("provider = \"bocha\""),
"an explicit provider change must persist: {contents}"
);
handle.abort();
Ok(())
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn set_config_search_provider_persist_skips_tavily_autodetect() -> Result<()> {
let _lock = lock_test_env();
let _code = EnvVarGuard::remove("CODEWHALE_SEARCH_PROVIDER");
let _legacy = EnvVarGuard::remove("DEEPSEEK_SEARCH_PROVIDER");
let _tavily = EnvVarGuard::remove("TAVILY_API_KEY");
let root = std::env::temp_dir().join(format!("codewhale-search-auto-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("config.toml");
fs::write(&config_file, "[search]\napi_key = \"tvly-autodetected\"\n")?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let (status, body) = post_set_config(&client, &addr, "search_provider", "tavily", true).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
assert_eq!(body["persisted"], false, "body: {body}");
assert!(
body["message"]
.as_str()
.is_some_and(|m| m.contains("tavily key")),
"the skip must explain the autodetect source: {body}"
);
let contents = fs::read_to_string(&config_file)?;
assert!(
!contents.contains("provider"),
"GET -> POST round-trip must not pin autodetect: {contents}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn reload_config_endpoint_returns_success() -> Result<()> {
// Basic smoke test that /v1/config/reload returns 200 with a message.
+88 -7
View File
@@ -8,7 +8,7 @@
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
};
use crate::config::SearchProvider;
use crate::config::{SearchProvider, tavily_env_key, tavily_key_from};
use crate::network_policy::{Decision, NetworkPolicyDecider};
use async_trait::async_trait;
use regex::Regex;
@@ -412,12 +412,23 @@ impl WebSearchTool {
timeout_ms: u64,
context: &ToolContext,
) -> Result<Vec<WebSearchEntry>, ToolError> {
let api_key = context
.search_api_key
.as_deref()
let api_key = tavily_key_from(context.search_api_key.as_deref())
.or_else(|| {
// An explicit `provider = "tavily"` still accepts any
// non-empty generic key, so a non-`tvly-` pin keeps working.
// Reaching this hop at all means Tavily was the resolved
// provider (pinned, or selected by a `tvly-` signal), so the
// generic fallback is never a Firecrawl/sentinel key.
context
.search_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.ok_or_else(|| {
ToolError::execution_failed(
"Tavily search requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml.",
"Tavily search requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml or the `TAVILY_API_KEY` env var.",
)
})?;
@@ -1080,8 +1091,8 @@ fn preflight_search_provider(context: &ToolContext) -> Result<(), ToolError> {
let not_configured = |message: &str| Err(ToolError::invalid_input(message));
match context.search_provider {
SearchProvider::Tavily if !configured_key => not_configured(
"Tavily search is not configured: it requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml.",
SearchProvider::Tavily if !configured_key && tavily_env_key().is_none() => not_configured(
"Tavily search is not configured: it requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml or the `TAVILY_API_KEY` env var.",
),
SearchProvider::Bocha if !configured_key => not_configured(
"Bocha search is not configured: it requires an API key. Set `[search] api_key = \"sk-...\"` in config.toml.",
@@ -3277,10 +3288,17 @@ mod tests {
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn missing_provider_key_fails_closed_as_not_configured() {
use crate::config::SearchProvider;
use crate::tools::spec::{ToolContext, ToolError, ToolSpec};
let _guard = crate::test_support::lock_test_env();
let prev_tavily = std::env::var_os("TAVILY_API_KEY");
// "both keys empty" must mean *both*: an ambient key from the
// operator's shell would otherwise satisfy the Tavily arm.
unsafe { std::env::remove_var("TAVILY_API_KEY") };
for provider in [SearchProvider::Tavily, SearchProvider::Bocha] {
let tmp = tempfile::tempdir().expect("tempdir");
let mut ctx = ToolContext::new(tmp.path().to_path_buf());
@@ -3299,6 +3317,69 @@ mod tests {
assert!(message.contains("is not configured"), "got `{message}`");
assert!(message.contains("api_key"), "got `{message}`");
}
// Sibling case: only `TAVILY_API_KEY` is set. Explicit Tavily is
// configured, and the copy that names both sources is the one the
// operator never sees here.
unsafe { std::env::set_var("TAVILY_API_KEY", "tvly-test-env-only") };
let tmp = tempfile::tempdir().expect("tempdir");
let mut ctx = ToolContext::new(tmp.path().to_path_buf());
ctx.search_provider = SearchProvider::Tavily;
ctx.search_api_key = None;
let preflight = super::preflight_search_provider(&ctx);
match prev_tavily {
Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
}
assert!(
preflight.is_ok(),
"TAVILY_API_KEY alone must configure explicit Tavily: {preflight:?}"
);
}
#[test]
fn tavily_key_from_prefers_dedicated_env_and_prefix_gates_only_the_generic_key() {
let _guard = crate::test_support::lock_test_env();
let prev = std::env::var_os("TAVILY_API_KEY");
unsafe { std::env::set_var("TAVILY_API_KEY", "tvly-a") };
assert_eq!(
crate::config::tavily_key_from(Some("tvly-b")).as_deref(),
Some("tvly-a"),
"the dedicated env wins over the shared generic slot"
);
assert_eq!(crate::config::tavily_env_key().as_deref(), Some("tvly-a"));
// A dedicated env key is never prefix-checked.
unsafe { std::env::set_var("TAVILY_API_KEY", "not-a-tvly-prefix") };
assert_eq!(
crate::config::tavily_key_from(None).as_deref(),
Some("not-a-tvly-prefix")
);
unsafe { std::env::set_var("TAVILY_API_KEY", " ") };
assert_eq!(crate::config::tavily_env_key(), None);
unsafe { std::env::remove_var("TAVILY_API_KEY") };
assert_eq!(
crate::config::tavily_key_from(Some("tvly-b")).as_deref(),
Some("tvly-b")
);
assert_eq!(
crate::config::tavily_key_from(Some("doctor-offline-search-sentinel")),
None,
"a non-`tvly-` generic key must never autodetect Tavily"
);
assert_eq!(crate::config::tavily_key_from(Some(" ")), None);
assert!(crate::config::looks_like_tavily_key(" tvly-x "));
assert!(!crate::config::looks_like_tavily_key("fc-live-test"));
match prev {
Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
}
}
#[tokio::test]
+11
View File
@@ -2932,6 +2932,17 @@ title, URL, and snippet. Set `[search] api_key` to your Serply key, or the
`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a
Serply model provider.
**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a
Tavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or
`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor
reports that as `source: tavily key`. Autodetect is runtime-only — Codewhale
never writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged
into `[search] api_key`. An explicit `[search] provider` or
`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = "firecrawl"` keeps
Firecrawl even with a Tavily key in the environment. Pinned `tavily` accepts
any non-empty `[search] api_key` and is configured by that key or
`TAVILY_API_KEY`; with both empty it fails closed.
```toml
[search]
provider = "firecrawl" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply
+2
View File
@@ -1341,6 +1341,8 @@ exec_policy = true
**Serply**([serply.io](https://serply.io))返回 Google 自然搜索结果,包含标题、URL 和摘要片段。把 `[search] api_key` 设为你的 Serply key,或用 `SERPLY_API_KEY` 环境变量。这只是搜索工具后端;它不添加 Serply 模型 provider。
**Tavily**([tavily.com](https://tavily.com))在存在 Tavily key 且没有固定 provider 时自动选中:设置了 `TAVILY_API_KEY`,或 `[search] api_key` / `CODEWHALE_SEARCH_API_KEY` 属于 `tvly-` 家族。doctor 会把这种情况报告为 `source: tavily key`。自动检测只在运行时生效——Codewhale 不会为它写入 `[search] provider`,也不会把 `TAVILY_API_KEY` 合并进 `[search] api_key`。显式的 `[search] provider` 或 `CODEWHALE_SEARCH_PROVIDER` 始终优先,所以即使环境里有 Tavily key,`provider = "firecrawl"` 仍然是 Firecrawl。固定为 `tavily` 时接受任何非空的 `[search] api_key`,由该 key 或 `TAVILY_API_KEY` 提供配置;两者都为空时直接失败关闭。
```toml
[search]
provider = "firecrawl" # 也 duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply