use crate::ai::{AiError, Effort, Provider, ProviderKind};
#[derive(Clone, Copy, Debug, Default)]
pub struct Connection<'a> {
pub provider: &'a str,
pub model: &'a str,
pub base_url: Option<&'a str>,
pub reasoning_effort: &'a str,
}
pub fn provider_for(connection: &Connection, key_for: impl Fn(&str) -> Option<String>) -> Result<Provider, AiError> {
if connection.provider.is_empty() || connection.provider.starts_with("acp:") {
return Err(AiError::Config("selecciona un proveedor de modelos; ACP usa su propia conexión"));
}
if connection.model.trim().is_empty() {
return Err(AiError::Config("configura el ID exacto del modelo de tu cuenta o servidor"));
}
if let Some(url) = connection.base_url {
validate_base_url(url)?;
}
if let Some(service) = crate::ai::oauth::Service::from_provider(connection.provider) {
if crate::ai::oauth::stored(service).is_none() {
return Err(AiError::Config("inicia sesión con tu suscripción desde el selector de modelos"));
}
return Ok(Provider {
effort: Effort::from_setting(connection.reasoning_effort),
..Provider::subscription(service)
});
}
let key = |id: &str| key_for(id).filter(|key| !key.trim().is_empty());
let mut provider = match (connection.provider, crate::ai::catalog::hosted(connection.provider)) {
("ollama", _) => Provider::ollama(),
(id, Some(hosted)) => Provider {
kind: hosted.kind,
base_url: hosted.base_url.to_owned(),
api_key: Some(key(id).ok_or(AiError::MissingKey(hosted.label))?),
effort: None,
},
(id, None) => {
let base_url = connection
.base_url
.map(str::to_owned)
.ok_or(AiError::Config("falta «base_url» para un servidor propio"))?;
Provider { kind: ProviderKind::OpenAiCompatible, base_url, api_key: key(id), effort: None }
}
};
if let Some(base_url) = connection.base_url {
provider.base_url = base_url.to_owned();
}
provider.effort = Effort::from_setting(connection.reasoning_effort);
Ok(provider)
}
pub fn validate_base_url(url: &str) -> Result<(), AiError> {
let invalid =
|| AiError::Config("«base_url» debe ser HTTP(S), con host válido y sin credenciales, query ni fragmento");
let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://")).ok_or_else(invalid)?;
if url.chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '\\' | '@' | '?' | '#')) {
return Err(invalid());
}
let authority = rest.split('/').next().unwrap_or_default();
let (host, port) = if let Some(ipv6) = authority.strip_prefix('[') {
let (ip, suffix) = ipv6.split_once(']').ok_or_else(invalid)?;
ip.parse::<std::net::Ipv6Addr>().map_err(|_| invalid())?;
(ip, if suffix.is_empty() { None } else { Some(suffix.strip_prefix(':').ok_or_else(invalid)?) })
} else {
let (host, port) = authority.split_once(':').map_or((authority, None), |(host, port)| (host, Some(port)));
if host.is_empty()
|| host.len() > 253
|| !host.trim_end_matches('.').split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
})
{
return Err(invalid());
}
(host, port)
};
if host.is_empty()
|| port.is_some_and(|port| {
port.is_empty()
|| !port.bytes().all(|b| b.is_ascii_digit())
|| port.parse::<u16>().ok().is_none_or(|port| port == 0)
})
{
return Err(invalid());
}
Ok(())
}