use std::{fmt, str::FromStr};
use rho_providers::credentials::WebSearchCredential;
use serde::{Deserialize, Serialize};
use url::Url;
#[path = "config_web_search_endpoint.rs"]
mod endpoint;
#[path = "config_web_search_migrate.rs"]
mod migrate;
#[cfg(test)]
use endpoint::join_api_path;
pub use endpoint::{
parse_search_endpoint_url, resolved_endpoint_url, BRAVE_API_DEFAULT_BASE, EXA_API_DEFAULT_BASE,
EXA_MCP_DEFAULT_URL, FIRECRAWL_API_DEFAULT_BASE, OPENAI_API_DEFAULT_BASE,
OPENAI_CODEX_RESPONSES_URL,
};
#[cfg(test)]
use migrate::migrate_legacy_web_search;
pub(super) use migrate::{resolve_web_search_settings, PartialWebSearchConfig};
#[cfg(test)]
pub(super) use migrate::{ExaSearchPartial, OpenAiSearchPartial};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WebSearchMode {
#[default]
Auto,
Backend,
Off,
}
impl WebSearchMode {
pub(crate) const ALL: [Self; 3] = [Self::Auto, Self::Backend, Self::Off];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Backend => "backend",
Self::Off => "off",
}
}
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Auto => "Auto",
Self::Backend => "Backend",
Self::Off => "Off",
}
}
}
impl fmt::Display for WebSearchMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for WebSearchMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"backend" => Ok(Self::Backend),
"off" => Ok(Self::Off),
other => Err(format!("unknown web search mode: {other}")),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchBackend {
#[default]
OpenAi,
Exa,
Brave,
Firecrawl,
}
impl SearchBackend {
pub(crate) const ALL: [Self; 4] = [Self::OpenAi, Self::Exa, Self::Brave, Self::Firecrawl];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::OpenAi => "openai",
Self::Exa => "exa",
Self::Brave => "brave",
Self::Firecrawl => "firecrawl",
}
}
pub(crate) const fn label(self) -> &'static str {
match self {
Self::OpenAi => "OpenAI",
Self::Exa => "Exa",
Self::Brave => "Brave",
Self::Firecrawl => "Firecrawl",
}
}
pub(crate) const fn default_api_base(self) -> &'static str {
match self {
Self::OpenAi => OPENAI_API_DEFAULT_BASE,
Self::Exa => EXA_API_DEFAULT_BASE,
Self::Brave => BRAVE_API_DEFAULT_BASE,
Self::Firecrawl => FIRECRAWL_API_DEFAULT_BASE,
}
}
pub(crate) const fn credential(self) -> WebSearchCredential {
match self {
Self::OpenAi => WebSearchCredential::OpenAi,
Self::Exa => WebSearchCredential::Exa,
Self::Brave => WebSearchCredential::Brave,
Self::Firecrawl => WebSearchCredential::Firecrawl,
}
}
pub(crate) const fn from_credential(credential: WebSearchCredential) -> Self {
match credential {
WebSearchCredential::OpenAi => Self::OpenAi,
WebSearchCredential::Exa => Self::Exa,
WebSearchCredential::Brave => Self::Brave,
WebSearchCredential::Firecrawl => Self::Firecrawl,
}
}
}
impl fmt::Display for SearchBackend {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for SearchBackend {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"openai" => Ok(Self::OpenAi),
"exa" => Ok(Self::Exa),
"brave" => Ok(Self::Brave),
"firecrawl" => Ok(Self::Firecrawl),
other => Err(format!("unknown web search backend: {other}")),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OpenAiSearchConnection {
#[default]
Api,
Codex,
}
impl OpenAiSearchConnection {
pub(crate) const ALL: [Self; 2] = [Self::Api, Self::Codex];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Api => "api",
Self::Codex => "codex",
}
}
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Api => "OpenAI API",
Self::Codex => "Codex",
}
}
}
impl fmt::Display for OpenAiSearchConnection {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for OpenAiSearchConnection {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"api" => Ok(Self::Api),
"codex" => Ok(Self::Codex),
other => Err(format!("unknown OpenAI search connection: {other}")),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExaSearchConnection {
#[default]
Api,
Mcp,
}
impl ExaSearchConnection {
pub(crate) const ALL: [Self; 2] = [Self::Api, Self::Mcp];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Api => "api",
Self::Mcp => "mcp",
}
}
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Api => "Exa API",
Self::Mcp => "Exa MCP",
}
}
}
impl fmt::Display for ExaSearchConnection {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ExaSearchConnection {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"api" => Ok(Self::Api),
"mcp" => Ok(Self::Mcp),
other => Err(format!("unknown Exa search connection: {other}")),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WebSearchEndpointSettings {
pub api_base_url: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OpenAiSearchSettings {
pub connection: OpenAiSearchConnection,
pub api_base_url: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExaSearchSettings {
pub connection: ExaSearchConnection,
pub api_base_url: Option<String>,
pub mcp_url: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WebSearchSettings {
pub mode: WebSearchMode,
pub backend: SearchBackend,
pub openai: OpenAiSearchSettings,
pub exa: ExaSearchSettings,
pub brave: WebSearchEndpointSettings,
pub firecrawl: WebSearchEndpointSettings,
}
impl WebSearchSettings {
pub(crate) fn endpoint(&self, backend: SearchBackend) -> Option<&str> {
match backend {
SearchBackend::OpenAi => self.openai.api_base_url.as_deref(),
SearchBackend::Exa => self.exa.api_base_url.as_deref(),
SearchBackend::Brave => self.brave.api_base_url.as_deref(),
SearchBackend::Firecrawl => self.firecrawl.api_base_url.as_deref(),
}
}
pub(crate) fn set_endpoint(&mut self, backend: SearchBackend, url: Option<String>) {
match backend {
SearchBackend::OpenAi => self.openai.api_base_url = url,
SearchBackend::Exa => self.exa.api_base_url = url,
SearchBackend::Brave => self.brave.api_base_url = url,
SearchBackend::Firecrawl => self.firecrawl.api_base_url = url,
}
}
pub(crate) fn destination(&self, backend: SearchBackend) -> SearchDestination<'_> {
match backend {
SearchBackend::OpenAi => match self.openai.connection {
OpenAiSearchConnection::Codex => SearchDestination::Fixed {
label: "Codex",
url: OPENAI_CODEX_RESPONSES_URL,
},
OpenAiSearchConnection::Api => SearchDestination::Resolved {
label: "OpenAI API",
configured: self.openai.api_base_url.as_deref(),
default_base: backend.default_api_base(),
},
},
SearchBackend::Exa => match self.exa.connection {
ExaSearchConnection::Api => SearchDestination::Resolved {
label: "Exa API",
configured: self.exa.api_base_url.as_deref(),
default_base: backend.default_api_base(),
},
ExaSearchConnection::Mcp => SearchDestination::Resolved {
label: "Exa MCP",
configured: self.exa.mcp_url.as_deref(),
default_base: EXA_MCP_DEFAULT_URL,
},
},
SearchBackend::Brave => SearchDestination::Resolved {
label: "Brave API",
configured: self.brave.api_base_url.as_deref(),
default_base: backend.default_api_base(),
},
SearchBackend::Firecrawl => SearchDestination::Resolved {
label: "Firecrawl API",
configured: self.firecrawl.api_base_url.as_deref(),
default_base: backend.default_api_base(),
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SearchDestination<'a> {
Fixed {
label: &'static str,
url: &'static str,
},
Resolved {
label: &'static str,
configured: Option<&'a str>,
default_base: &'static str,
},
}
impl<'a> SearchDestination<'a> {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Fixed { label, .. } | Self::Resolved { label, .. } => label,
}
}
pub(crate) fn configured(self) -> Option<&'a str> {
match self {
Self::Fixed { .. } => None,
Self::Resolved { configured, .. } => configured,
}
}
pub(crate) fn resolve_path(self, path: &str) -> anyhow::Result<Url> {
match self {
Self::Fixed { url, .. } => Url::parse(url).map_err(anyhow::Error::from),
Self::Resolved {
configured,
default_base,
..
} => resolved_endpoint_url(configured, default_base, path),
}
}
pub(crate) fn is_default_origin(self) -> bool {
match self {
Self::Fixed { .. } => true,
Self::Resolved {
configured,
default_base,
..
} => {
let Some(configured) = configured.map(str::trim).filter(|value| !value.is_empty())
else {
return true;
};
let Ok(parsed) = parse_search_endpoint_url("web search endpoint", configured)
else {
return false;
};
let Ok(default) = Url::parse(default_base) else {
return false;
};
endpoint::same_origin_and_prefix(&parsed, &default)
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WebSearchRoute {
Off,
Native,
Backend(SearchBackend),
}
pub(crate) fn web_search_route(
settings: &WebSearchSettings,
hosted_supported: bool,
) -> WebSearchRoute {
match settings.mode {
WebSearchMode::Off => WebSearchRoute::Off,
WebSearchMode::Backend => WebSearchRoute::Backend(settings.backend),
WebSearchMode::Auto => {
if hosted_supported {
WebSearchRoute::Native
} else {
WebSearchRoute::Backend(settings.backend)
}
}
}
}
#[cfg(test)]
#[path = "config_web_search_tests.rs"]
mod tests;