mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
a1840d4594
commit
ff695506e0
11 files changed
+674
-42
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user