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.
//! Interfaz común de los proveedores y su configuración.

use crate::ai::oauth::{self, Service};
use crate::ai::sse::SseEvent;
use crate::ai::types::{AiError, Event, Request};
use crate::ai::{anthropic, codex, http, openai};
use crate::core::CancelToken;
use serde_json::Value;

/// Convierte los eventos SSE de un proveedor en eventos comunes.
pub trait Decoder {
    fn feed(&mut self, event: &SseEvent, out: &mut Vec<Event>) -> Result<(), AiError>;
    /// El stream terminó: entrega lo que quedara pendiente (idempotente).
    fn finish(&mut self, out: &mut Vec<Event>);
    /// `true` si el proveedor marcó el final de la respuesta; sin eso, un cierre de conexión es un corte.
    fn is_complete(&self) -> bool;
}

/// Dirección, cabeceras, cuerpo y decodificador de una petición ya armada.
type Call = (String, Vec<(String, String)>, Value, Box<dyn Decoder>);

/// Un modelo capaz de responder en streaming. Es bloqueante: se llama desde un hilo de trabajo.
pub trait LanguageModel: Send + Sync {
    fn stream(&self, request: &Request, cancel: &CancelToken, emit: &mut dyn FnMut(Event)) -> Result<(), AiError>;
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderKind {
    Anthropic,
    /// OpenAI y todo lo que habla su protocolo: OpenRouter, LM Studio, vLLM y Ollama.
    OpenAiCompatible,
    /// Claude con la suscripción Pro/Max (OAuth, sin clave de API).
    ClaudeSubscription,
    /// ChatGPT Plus/Pro por el backend de Codex (OAuth, sin clave de API).
    ChatGptSubscription,
}

#[derive(Clone, Debug)]
pub struct Provider {
    pub kind: ProviderKind,
    pub base_url: String,
    /// `None` en servidores locales sin autenticación (Ollama).
    pub api_key: Option<String>,
    /// Esfuerzo de razonamiento pedido al modelo; `None` deja el que el proveedor use por defecto.
    pub effort: Option<Effort>,
}

/// Cuánto debe razonar el modelo: `output_config.effort` en Anthropic, `reasoning_effort` en OpenAI.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effort {
    /// Sin razonamiento previo: la respuesta empieza en seguida. Solo para lo que debe ser inmediato (la
    /// predicción al escribir); el backend de Codex lo acepta como `none`.
    Off,
    Low,
    Medium,
    High,
    Max,
}

impl Effort {
    /// Valor del ajuste `agent.reasoning_effort`; `auto` (o cualquier otro) no fuerza nada.
    pub fn from_setting(value: &str) -> Option<Self> {
        match value {
            "low" => Some(Self::Low),
            "medium" => Some(Self::Medium),
            "high" => Some(Self::High),
            "max" => Some(Self::Max),
            _ => None,
        }
    }

    fn anthropic(self) -> &'static str {
        match self {
            // Anthropic no tiene «sin razonamiento»: el más rápido es `low`.
            Self::Off | Self::Low => "low",
            Self::Medium => "medium",
            Self::High => "high",
            Self::Max => "max",
        }
    }

    /// OpenAI no tiene «max»: el más alto que acepta todo el catálogo es `high`.
    fn openai(self) -> &'static str {
        match self {
            Self::Off => "none",
            Self::Low => "low",
            Self::Medium => "medium",
            Self::High | Self::Max => "high",
        }
    }
}

impl Provider {
    pub fn anthropic(api_key: String) -> Self {
        Self {
            kind: ProviderKind::Anthropic,
            base_url: "https://api.anthropic.com".into(),
            api_key: Some(api_key),
            effort: None,
        }
    }

    pub fn openai(api_key: String) -> Self {
        Self {
            kind: ProviderKind::OpenAiCompatible,
            base_url: "https://api.openai.com/v1".into(),
            api_key: Some(api_key),
            effort: None,
        }
    }

    pub fn ollama() -> Self {
        Self {
            kind: ProviderKind::OpenAiCompatible,
            base_url: "http://localhost:11434/v1".into(),
            api_key: None,
            effort: None,
        }
    }

    /// Sesión por suscripción: no hay clave ni dirección propia, todo viene del servicio y de sus tokens.
    pub fn subscription(service: Service) -> Self {
        match service {
            Service::Claude => Self {
                kind: ProviderKind::ClaudeSubscription,
                base_url: "https://api.anthropic.com".into(),
                api_key: None,
                effort: None,
            },
            Service::ChatGpt => Self {
                kind: ProviderKind::ChatGptSubscription,
                base_url: codex::BASE_URL.into(),
                api_key: None,
                effort: None,
            },
        }
    }

    fn auth_headers(&self) -> Vec<(String, String)> {
        match self.kind {
            ProviderKind::Anthropic | ProviderKind::ClaudeSubscription => {
                let mut headers = vec![("anthropic-version".to_owned(), "2023-06-01".to_owned())];
                headers.extend(self.api_key.iter().map(|key| ("x-api-key".to_owned(), key.clone())));
                headers
            }
            ProviderKind::OpenAiCompatible | ProviderKind::ChatGptSubscription => {
                self.api_key.iter().map(|key| ("authorization".to_owned(), format!("Bearer {key}"))).collect()
            }
        }
    }

    /// Dirección y cabeceras para listar los modelos de la cuenta.
    pub fn models_endpoint(&self) -> (String, Vec<(String, String)>) {
        if let Some(url) = crate::ai::opencode::models_url(&self.base_url) {
            return (url, self.auth_headers());
        }
        let base = self.base_url.trim_end_matches('/');
        let path = if matches!(self.kind, ProviderKind::Anthropic | ProviderKind::ClaudeSubscription) {
            "v1/models?limit=100"
        } else {
            "models"
        };
        (format!("{base}/{path}"), self.auth_headers())
    }

    fn endpoint(&self) -> (String, Vec<(String, String)>) {
        let base = self.base_url.trim_end_matches('/');
        match self.kind {
            ProviderKind::ChatGptSubscription => (base.to_owned(), Vec::new()),
            ProviderKind::Anthropic | ProviderKind::ClaudeSubscription => {
                (format!("{base}/v1/messages"), self.auth_headers())
            }
            ProviderKind::OpenAiCompatible => (format!("{base}/chat/completions"), self.auth_headers()),
        }
    }
}

impl Provider {
    /// Añade el esfuerzo de razonamiento elegido con el nombre que usa cada API.
    fn with_effort(&self, mut body: Value) -> Value {
        let Some(effort) = self.effort else { return body };
        match self.kind {
            ProviderKind::Anthropic | ProviderKind::ClaudeSubscription => {
                body["output_config"] = serde_json::json!({ "effort": effort.anthropic() });
            }
            ProviderKind::OpenAiCompatible => body["reasoning_effort"] = effort.openai().into(),
            ProviderKind::ChatGptSubscription => body["reasoning"] = serde_json::json!({ "effort": effort.openai() }),
        }
        body
    }
}

impl LanguageModel for Provider {
    fn stream(&self, request: &Request, cancel: &CancelToken, emit: &mut dyn FnMut(Event)) -> Result<(), AiError> {
        self.stream_with(http::RetryPolicy::default(), request, cancel, emit)
    }
}

impl Provider {
    /// Una sola petición, sin reintentos: para respuestas que caducan en segundos (la predicción al escribir),
    /// donde reintentar tras una espera solo traería texto que ya no sirve.
    pub fn stream_once(
        &self,
        request: &Request,
        cancel: &CancelToken,
        emit: &mut dyn FnMut(Event),
    ) -> Result<(), AiError> {
        self.stream_with(http::RetryPolicy { retries: 0, ..Default::default() }, request, cancel, emit)
    }

    fn stream_with(
        &self,
        policy: http::RetryPolicy,
        request: &Request,
        cancel: &CancelToken,
        emit: &mut dyn FnMut(Event),
    ) -> Result<(), AiError> {
        let request = &*crate::ai::wire::prepare(request);
        let (url, headers) = self.endpoint();
        let (url, mut headers, body, mut decoder): Call = match self.kind {
            ProviderKind::Anthropic => {
                (url, headers, anthropic::body(request), Box::new(anthropic::AnthropicDecoder::default()))
            }
            ProviderKind::OpenAiCompatible => {
                (url, headers, openai::body(request), Box::new(openai::OpenAiDecoder::default()))
            }
            ProviderKind::ClaudeSubscription => {
                let session = oauth::fresh(Service::Claude)?;
                let body = anthropic::subscription_body(request);
                let headers = anthropic::subscription_headers(&session.access);
                (url, headers, body, Box::new(anthropic::AnthropicDecoder::default()))
            }
            ProviderKind::ChatGptSubscription => {
                let session = oauth::fresh(Service::ChatGpt)?;
                let headers = codex::headers(&session.access, session.account_id.as_deref().unwrap_or_default());
                (url, headers, codex::body(request), Box::new(codex::CodexDecoder::default()))
            }
        };
        if crate::ai::opencode::is_opencode(&self.base_url) {
            headers.extend(crate::ai::opencode::headers(request));
        }
        let mut body = self.with_effort(body);
        if self.kind == ProviderKind::OpenAiCompatible && self.base_url.contains("api.openai.com") {
            // Los modelos actuales de OpenAI rechazan `max_tokens`; los servidores compatibles aún lo esperan.
            if let Some(limit) = body.as_object_mut().and_then(|body| body.remove("max_tokens")) {
                body["max_completion_tokens"] = limit;
            }
        }
        http::stream_sse_with(policy, &url, &headers, &body, cancel, decoder.as_mut(), emit)
    }
}

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

    #[test]
    fn el_esfuerzo_usa_el_nombre_de_cada_api() {
        let with = |mut provider: Provider, effort| {
            provider.effort = effort;
            provider.with_effort(serde_json::json!({ "model": "m" }))
        };
        let claude = with(Provider::anthropic("k".into()), Some(Effort::Max));
        assert_eq!(claude["output_config"]["effort"], "max");
        let openai = with(Provider::openai("k".into()), Some(Effort::Max));
        assert_eq!(openai["reasoning_effort"], "high");
        let codex = with(Provider::subscription(Service::ChatGpt), Some(Effort::Low));
        assert_eq!(codex["reasoning"]["effort"], "low");
        assert_eq!(with(Provider::anthropic("k".into()), None), serde_json::json!({ "model": "m" }));
        assert_eq!(Effort::from_setting("auto"), None);
        assert_eq!(Effort::from_setting("medium"), Some(Effort::Medium));
    }
}