aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Construye el proveedor de una conexión (proveedor, modelo, URL y esfuerzo) sin consultar otros proveedores. Lo
//! comparten el editor y `aura_memory`; cada uno adapta sus ajustes a [`Connection`].

use crate::ai::{AiError, Effort, Provider, ProviderKind};

/// Lo mínimo para hablar con un modelo: qué proveedor, qué ID exacto, URL propia y esfuerzo de razonamiento.
#[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,
}

/// `key_for` devuelve la clave de un proveedor (en la app, `crate::ai::secrets::get`).
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"))?;
            // Un servidor con nombre propio no hereda la clave de «custom», OpenAI ni Anthropic.
            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)
}

/// Validación local conservadora: HTTP(S), host y puerto válidos, sin credenciales/query/fragmento.
/// No se resuelve DNS ni se intenta conectar; los hosts locales y las direcciones IPv6 son válidos.
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(())
}