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";
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()
}
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
}
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"]);
}
}