aura-gui 0.1.0

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.
//! Lista de modelos disponibles para la cuenta: se pregunta al proveedor, no se supone un catálogo.

use crate::ai::oauth::{self, Service};
use crate::ai::provider::{Provider, ProviderKind};
use crate::ai::types::AiError;
use crate::ai::{anthropic, codex};
use serde_json::Value;
use std::io::Read;
use std::time::Duration;

const TIMEOUT: Duration = Duration::from_secs(10);
const MAX_BODY: u64 = 2_000_000;

/// IDs de la respuesta: `{"data":[{"id":…}]}` (Anthropic, OpenAI, OpenRouter, LM Studio, Ollama) o
/// `{"models":[{"slug":…}]}` (ChatGPT, que además marca con `visibility` los que no se ofrecen). Sin repetidos.
pub fn parse(body: &str) -> Result<Vec<String>, AiError> {
    let value: Value = serde_json::from_str(body).map_err(|error| AiError::Decode(error.to_string()))?;
    let (items, key) = match (value["data"].as_array(), value["models"].as_array()) {
        (Some(items), _) => (items, "id"),
        (None, Some(items)) => (items, "slug"),
        _ => return Err(AiError::Decode("falta la lista de modelos".into())),
    };
    let mut ids: Vec<String> = Vec::new();
    for item in items.iter().filter(|item| item["visibility"].as_str().is_none_or(|v| v == "list")) {
        if let Some(id) = item[key].as_str().filter(|id| !ids.iter().any(|known| known == id)) {
            ids.push(id.to_owned());
        }
    }
    Ok(ids)
}

/// Dirección y cabeceras de la consulta; con suscripción hace falta una sesión vigente.
fn endpoint(provider: &Provider) -> Result<(String, Vec<(String, String)>), AiError> {
    Ok(match provider.kind {
        ProviderKind::ClaudeSubscription => {
            let session = oauth::fresh(Service::Claude)?;
            (format!("{}/v1/models?limit=100", provider.base_url), anthropic::subscription_headers(&session.access))
        }
        ProviderKind::ChatGptSubscription => {
            let session = oauth::fresh(Service::ChatGpt)?;
            let headers = codex::headers(&session.access, session.account_id.as_deref().unwrap_or_default());
            (codex::models_url(), headers)
        }
        _ => provider.models_endpoint(),
    })
}

/// Pide los modelos al proveedor (bloqueante: llamar desde un hilo de trabajo).
pub fn fetch(provider: &Provider) -> Result<Vec<String>, AiError> {
    let (url, headers) = endpoint(provider)?;
    let agent: ureq::Agent =
        ureq::Agent::config_builder().timeout_global(Some(TIMEOUT)).http_status_as_error(false).build().into();
    let mut request = agent.get(&url);
    for (name, value) in &headers {
        request = request.header(name, value);
    }
    let response = request.call().map_err(|error| AiError::Transport(error.to_string()))?;
    let code = response.status().as_u16();
    let mut text = String::new();
    response
        .into_body()
        .into_reader()
        .take(MAX_BODY)
        .read_to_string(&mut text)
        .map_err(|error| AiError::Transport(error.to_string()))?;
    if code >= 400 {
        return Err(AiError::Status { code, body: text.chars().take(500).collect() });
    }
    let mut ids = parse(&text)?;
    // Anthropic ya ordena lo más nuevo primero; el resto se ordena para que el selector sea predecible.
    if provider.kind != ProviderKind::Anthropic {
        ids.sort();
    }
    Ok(ids)
}

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

    #[test]
    fn lee_los_ids_sin_repetidos() {
        let body = r#"{"data":[{"id":"a","type":"model"},{"id":"b"},{"id":"a"},{"nombre":"x"}]}"#;
        assert_eq!(parse(body).unwrap(), ["a", "b"]);
    }

    #[test]
    fn chatgpt_lista_slugs_y_oculta_los_que_no_se_ofrecen() {
        let body = r#"{"models":[{"slug":"a","visibility":"list"},{"slug":"b","visibility":"hide"},{"slug":"c"}]}"#;
        assert_eq!(parse(body).unwrap(), ["a", "c"]);
    }

    #[test]
    fn rechaza_lo_que_no_es_una_lista() {
        assert!(parse(r#"{"error":"x"}"#).is_err());
        assert!(parse("no es json").is_err());
    }
}