aura-gui 0.2.2

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.
//! Tipos comunes a todos los proveedores: mensajes, herramientas y eventos del stream.

use serde_json::Value;
use std::fmt;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
    User,
    Assistant,
}

/// Un trozo de contenido de un mensaje.
#[derive(Clone, Debug, PartialEq)]
pub enum Block {
    Text(String),
    /// Imagen adjunta al mensaje, ya codificada en base64 (sin el prefijo `data:`).
    Image {
        media_type: String,
        data_base64: String,
        /// Nombre visible; solo sirve para reconstruir el adjunto al reabrir la conversación.
        name: Option<String>,
    },
    /// El modelo pide ejecutar una herramienta.
    ToolUse {
        id: String,
        name: String,
        input: Value,
    },
    /// Resultado de una herramienta, que se devuelve al modelo en un mensaje de usuario.
    ToolResult {
        id: String,
        content: String,
        is_error: bool,
    },
}

#[derive(Clone, Debug, PartialEq)]
pub struct Message {
    pub role: Role,
    pub blocks: Vec<Block>,
}

impl Message {
    pub fn user(text: impl Into<String>) -> Self {
        Self { role: Role::User, blocks: vec![Block::Text(text.into())] }
    }

    pub fn assistant(blocks: Vec<Block>) -> Self {
        Self { role: Role::Assistant, blocks }
    }
}

/// Herramienta que el modelo puede llamar; `schema` es un JSON Schema de sus argumentos.
#[derive(Clone, Debug, PartialEq)]
pub struct ToolSpec {
    pub name: String,
    pub description: String,
    pub schema: Value,
}

#[derive(Clone, Debug)]
pub struct Request {
    pub model: String,
    pub system: Option<String>,
    pub messages: Vec<Message>,
    pub tools: Vec<ToolSpec>,
    pub max_tokens: u32,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StopReason {
    EndTurn,
    ToolUse,
    MaxTokens,
    Other(String),
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Usage {
    pub input_tokens: u32,
    pub output_tokens: u32,
}

/// Lo que va llegando del modelo.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
    Text(String),
    /// Llamada a herramienta ya completa (los argumentos parciales no se exponen).
    ToolUse {
        id: String,
        name: String,
        input: Value,
    },
    Usage(Usage),
    Done(StopReason),
}

#[derive(Debug)]
pub enum AiError {
    /// Fallo de red o de lectura.
    Transport(String),
    /// El servidor respondió con un código de error.
    Status {
        code: u16,
        body: String,
    },
    /// La respuesta no tenía el formato esperado.
    Decode(String),
    /// El proveedor informó un error dentro del stream.
    Api(String),
    Cancelled,
    MissingKey(&'static str),
    /// Ajustes incompletos o inválidos.
    Config(&'static str),
}

/// Mensaje legible de un error HTTP: qué significa el código y, si el cuerpo trae uno, el detalle del proveedor.
fn status_message(code: u16, body: &str) -> String {
    let meaning = match code {
        400 | 422 => "el proveedor rechazó la petición",
        401 => "clave o sesión no válida: revisa la conexión en el selector de modelos",
        403 => "esta cuenta no tiene permiso para usar este modelo o servicio",
        404 => "modelo o dirección no encontrados: revisa el ID del modelo y la dirección base",
        413 => "la conversación es demasiado grande para el modelo",
        429 => "demasiadas peticiones o cuota agotada: espera un momento",
        529 => "el servicio de modelos está saturado: inténtalo de nuevo en un rato",
        500..=599 => "el servicio de modelos falló: inténtalo de nuevo",
        _ => "error del servidor",
    };
    let json: Option<Value> = serde_json::from_str(body).ok();
    let detail = json
        .as_ref()
        .and_then(|json| json["error"]["message"].as_str().or(json["error"].as_str()).or(json["message"].as_str()))
        .map(str::to_owned)
        .or_else(|| Some(body.trim().to_owned()).filter(|text| !text.is_empty() && json.is_none()));
    match detail {
        Some(detail) => format!("{meaning} ({code}): {detail}"),
        None => format!("{meaning} ({code})"),
    }
}

impl fmt::Display for AiError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AiError::Transport(message) => write!(formatter, "error de red: {message}"),
            AiError::Status { code, body } => write!(formatter, "{}", status_message(*code, body)),
            AiError::Decode(message) => write!(formatter, "respuesta inválida: {message}"),
            AiError::Api(message) => write!(formatter, "error del proveedor: {message}"),
            AiError::Cancelled => write!(formatter, "cancelado"),
            AiError::MissingKey(provider) => write!(formatter, "falta la clave de API de {provider}"),
            AiError::Config(message) => write!(formatter, "ajustes de IA: {message}"),
        }
    }
}

impl std::error::Error for AiError {}

#[cfg(test)]
mod tests {
    use super::*;

    fn status(code: u16, body: &str) -> String {
        AiError::Status { code, body: body.into() }.to_string()
    }

    #[test]
    fn los_errores_http_se_explican_y_conservan_el_detalle() {
        let anthropic =
            status(401, r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#);
        assert_eq!(
            anthropic,
            "clave o sesión no válida: revisa la conexión en el selector de modelos (401): invalid x-api-key"
        );
        assert!(status(404, r#"{"error":{"message":"model: x"}}"#).contains("revisa el ID del modelo"));
        assert!(status(429, "").contains("espera un momento") && status(529, "{}").contains("saturado"));
        assert_eq!(
            status(502, "<html>Bad Gateway</html>"),
            "el servicio de modelos falló: inténtalo de nuevo (502): <html>Bad Gateway</html>"
        );
        assert_eq!(
            status(400, r#"{"error":"clave inválida"}"#),
            "el proveedor rechazó la petición (400): clave inválida"
        );
        assert_eq!(status(418, ""), "error del servidor (418)");
    }
}