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.
//! API de Responses que usa el backend de ChatGPT (el de Codex): cuerpo de la petición y decodificador del stream.

use crate::ai::oauth::pkce::{base64url, sha256};
use crate::ai::provider::Decoder;
use crate::ai::sse::SseEvent;
use crate::ai::types::{AiError, Block, Event, Message, Request, Role, StopReason, Usage};
use serde_json::{Value, json};

pub const BASE_URL: &str = "https://chatgpt.com/backend-api/codex/responses";

/// Lista de modelos de la cuenta; el backend pide la versión del cliente.
pub fn models_url() -> String {
    "https://chatgpt.com/backend-api/codex/models?client_version=0.159.3".to_owned()
}

pub fn headers(token: &str, account_id: &str) -> Vec<(String, String)> {
    [
        ("authorization", format!("Bearer {token}")),
        ("chatgpt-account-id", account_id.to_owned()),
        ("openai-beta", "responses=experimental".to_owned()),
        ("originator", "aura".to_owned()),
        ("user-agent", "aura".to_owned()),
    ]
    .into_iter()
    .map(|(name, value)| (name.to_owned(), value))
    .collect()
}

/// Cada bloque de un mensaje es un elemento de `input`; las herramientas son elementos propios, no anidados.
fn input_items(message: &Message) -> Vec<Value> {
    let (text_type, role) =
        if message.role == Role::User { ("input_text", "user") } else { ("output_text", "assistant") };
    let mut items = Vec::new();
    let mut parts: Vec<Value> = Vec::new();
    let flush = |parts: &mut Vec<Value>, items: &mut Vec<Value>| {
        if !parts.is_empty() {
            items.push(json!({ "type": "message", "role": role, "content": std::mem::take(parts) }));
        }
    };
    for block in &message.blocks {
        match block {
            Block::Text(text) => parts.push(json!({ "type": text_type, "text": text })),
            Block::Image { media_type, data_base64, .. } => parts
                .push(json!({ "type": "input_image", "image_url": format!("data:{media_type};base64,{data_base64}") })),
            Block::ToolUse { id, name, input } => {
                flush(&mut parts, &mut items);
                items.push(
                    json!({ "type": "function_call", "call_id": id, "name": name, "arguments": input.to_string() }),
                );
            }
            Block::ToolResult { id, content, .. } => {
                flush(&mut parts, &mut items);
                items.push(json!({ "type": "function_call_output", "call_id": id, "output": content }));
            }
        }
    }
    flush(&mut parts, &mut items);
    items
}

/// Clave de caché estable por conversación: el servidor reparte las peticiones con el mismo prefijo a la misma
/// caché, así cada vuelta del agente solo paga lo nuevo.
fn cache_key(request: &Request) -> String {
    let first = request.messages.first().and_then(|message| {
        message.blocks.iter().find_map(|block| if let Block::Text(text) = block { Some(text.as_str()) } else { None })
    });
    let seed = format!("{}\u{0}{}", request.system.as_deref().unwrap_or_default(), first.unwrap_or_default());
    base64url(&sha256(seed.as_bytes()))[..22].to_owned()
}

pub fn body(request: &Request) -> Value {
    let mut body = json!({
        "model": request.model,
        "store": false,
        "stream": true,
        "instructions": request.system.clone().unwrap_or_else(|| "You are a helpful assistant.".into()),
        "input": request.messages.iter().flat_map(input_items).collect::<Vec<_>>(),
        "text": { "verbosity": "low" },
        "prompt_cache_key": cache_key(request),
        "tool_choice": "auto",
        "parallel_tool_calls": true,
    });
    if !request.tools.is_empty() {
        body["tools"] = request
            .tools
            .iter()
            .map(|tool| json!({ "type": "function", "name": tool.name, "description": tool.description, "parameters": tool.schema }))
            .collect();
    }
    body
}

#[derive(Default)]
pub struct CodexDecoder {
    usage: Usage,
    tool_calls: bool,
    stop: Option<StopReason>,
    finished: bool,
    complete: bool,
}

impl Decoder for CodexDecoder {
    fn feed(&mut self, event: &SseEvent, out: &mut Vec<Event>) -> Result<(), AiError> {
        let data: Value = match serde_json::from_str(&event.data) {
            Ok(data) => data,
            Err(_) if event.data.is_empty() || event.data == "[DONE]" => return Ok(()),
            Err(error) => return Err(AiError::Decode(error.to_string())),
        };
        match data["type"].as_str() {
            Some("response.output_text.delta") => {
                if let Some(text) = data["delta"].as_str().filter(|text| !text.is_empty()) {
                    out.push(Event::Text(text.to_owned()));
                }
            }
            Some("response.output_item.done") if data["item"]["type"] == "function_call" => {
                let item = &data["item"];
                let arguments = item["arguments"].as_str().unwrap_or_default();
                let name = item["name"].as_str().unwrap_or_default().to_owned();
                let input = crate::ai::wire::tool_input(arguments);
                self.tool_calls = true;
                let id = item["call_id"].as_str().or(item["id"].as_str()).unwrap_or_default().to_owned();
                out.push(Event::ToolUse { id, name, input });
            }
            Some("response.completed" | "response.done" | "response.incomplete") => {
                let response = &data["response"];
                self.usage = Usage {
                    input_tokens: response["usage"]["input_tokens"].as_u64().unwrap_or(0) as u32,
                    output_tokens: response["usage"]["output_tokens"].as_u64().unwrap_or(0) as u32,
                };
                if response["incomplete_details"]["reason"] == "max_output_tokens" {
                    self.stop = Some(StopReason::MaxTokens);
                }
                self.complete = true;
                self.finish(out);
            }
            Some("response.failed") => {
                let message = data["response"]["error"]["message"].as_str().unwrap_or("la respuesta falló");
                return Err(AiError::Api(message.to_owned()));
            }
            Some("error") => {
                let message = data["message"].as_str().or(data["error"]["message"].as_str()).unwrap_or("error");
                return Err(AiError::Api(message.to_owned()));
            }
            _ => {}
        }
        Ok(())
    }

    fn finish(&mut self, out: &mut Vec<Event>) {
        if std::mem::replace(&mut self.finished, true) {
            return;
        }
        let stop = self.stop.take().unwrap_or(if self.tool_calls { StopReason::ToolUse } else { StopReason::EndTurn });
        out.push(Event::Usage(self.usage));
        out.push(Event::Done(stop));
    }

    fn is_complete(&self) -> bool {
        self.complete
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ai::sse::SseParser;
    use crate::ai::types::ToolSpec;

    fn decode(stream: &str) -> Result<Vec<Event>, AiError> {
        let (mut parser, mut events, mut out) = (SseParser::default(), Vec::new(), Vec::new());
        let mut decoder = CodexDecoder::default();
        parser.push(stream.as_bytes(), &mut events);
        for event in &events {
            decoder.feed(event, &mut out)?;
        }
        decoder.finish(&mut out);
        Ok(out)
    }

    #[test]
    fn texto_herramienta_y_uso() {
        let stream = "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"Hola\"}\n\n\
            event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"a.rs\\\"}\"}}\n\n\
            event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}}\n\n";
        assert_eq!(
            decode(stream).unwrap(),
            [
                Event::Text("Hola".into()),
                Event::ToolUse { id: "c1".into(), name: "read_file".into(), input: json!({"path": "a.rs"}) },
                Event::Usage(Usage { input_tokens: 7, output_tokens: 3 }),
                Event::Done(StopReason::ToolUse),
            ]
        );
    }

    #[test]
    fn sin_herramientas_termina_el_turno_y_los_fallos_son_errores() {
        let done = "data: {\"type\":\"response.completed\",\"response\":{}}\n\n";
        assert_eq!(decode(done).unwrap().last(), Some(&Event::Done(StopReason::EndTurn)));
        let failed = "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"límite\"}}}\n\n";
        assert!(matches!(decode(failed).unwrap_err(), AiError::Api(message) if message == "límite"));
    }

    #[test]
    fn el_cuerpo_separa_mensajes_llamadas_y_resultados() {
        let request = Request {
            model: "gpt".into(),
            system: Some("Sé breve".into()),
            messages: vec![
                Message::user("hola"),
                Message::assistant(vec![
                    Block::Text("voy".into()),
                    Block::ToolUse { id: "t".into(), name: "ls".into(), input: json!({}) },
                ]),
                Message {
                    role: Role::User,
                    blocks: vec![Block::ToolResult { id: "t".into(), content: "a".into(), is_error: false }],
                },
            ],
            tools: vec![ToolSpec { name: "ls".into(), description: "lista".into(), schema: json!({"type": "object"}) }],
            max_tokens: 10,
        };
        let body = body(&request);
        assert_eq!(body["instructions"], "Sé breve");
        assert_eq!(body["store"], false);
        assert_eq!(body["input"][0]["content"][0]["type"], "input_text");
        assert_eq!(body["input"][1]["content"][0]["type"], "output_text");
        assert_eq!(body["input"][2]["type"], "function_call");
        assert_eq!(body["input"][3], json!({"type": "function_call_output", "call_id": "t", "output": "a"}));
        assert_eq!(body["tools"][0]["name"], "ls");
        assert_eq!(body["prompt_cache_key"], super::body(&request)["prompt_cache_key"]);
    }
}