1use std::path::PathBuf;
2use std::sync::Arc;
3
4use crate::llm::http::ReqwestHttpClient;
5use crate::llm::{
6 default_http_client, structured, ContentBlock, HttpClient, LlmClient, LlmResponse,
7 LlmResponseMeta, Message, ModelGenerationPool, StreamEvent, TokenUsage, ToolDefinition,
8};
9use anyhow::{anyhow, Context, Result};
10use async_trait::async_trait;
11use serde_json::{json, Value};
12use tokio::sync::mpsc;
13use tokio_util::sync::CancellationToken;
14
15const CODEX_BASE: &str = "https://chatgpt.com/backend-api/codex";
16const ORIGINATOR: &str = "codex_cli_rs";
17const USER_AGENT: &str = "codex_cli_rs (a3s-code-core)";
18
19pub struct CodexLoginClient {
24 access_token: String,
25 account_id: String,
26 model: String,
27 reasoning_effort: Option<String>,
28 session_id: String,
29 http: Arc<dyn HttpClient>,
30 encrypted_reasoning: Arc<std::sync::Mutex<Vec<Option<String>>>>,
34}
35
36impl CodexLoginClient {
37 pub fn from_local_login(model: &str, session_id: &str) -> Result<Self> {
39 let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME unset"))?;
40 let auth_path = PathBuf::from(home).join(".codex/auth.json");
41 let reasoning_effort = std::env::var("A3S_CODEX_REASONING_EFFORT")
42 .ok()
43 .filter(|effort| !effort.trim().is_empty());
44 Self::from_auth_file(auth_path, model, session_id, reasoning_effort)
45 }
46
47 pub fn from_auth_file(
48 auth_path: impl Into<PathBuf>,
49 model: &str,
50 session_id: &str,
51 reasoning_effort: Option<String>,
52 ) -> Result<Self> {
53 let auth_path = auth_path.into();
54 let raw = crate::bounded_io::read_utf8_file_bounded(
55 &auth_path,
56 crate::bounded_io::MAX_AUTH_FILE_BYTES,
57 )
58 .with_context(|| format!("read Codex auth file {}", auth_path.display()))?;
59 let auth: Value = serde_json::from_str(&raw).context("parse Codex auth file")?;
60
61 let access_token = auth
62 .pointer("/tokens/access_token")
63 .or_else(|| auth.get("access_token"))
64 .and_then(Value::as_str)
65 .ok_or_else(|| anyhow!("no access_token in ~/.codex/auth.json; run `codex login`"))?
66 .to_string();
67
68 let account_id = auth
69 .pointer("/tokens/account_id")
70 .and_then(Value::as_str)
71 .map(str::to_string)
72 .or_else(|| {
73 auth.pointer("/tokens/id_token")
74 .and_then(Value::as_str)
75 .and_then(account_id_from_id_token)
76 })
77 .ok_or_else(|| {
78 anyhow!("no ChatGPT account id in ~/.codex/auth.json; re-run `codex login`")
79 })?;
80
81 Ok(Self {
82 access_token,
83 account_id,
84 model: model.to_string(),
85 reasoning_effort: reasoning_effort.filter(|effort| !effort.trim().is_empty()),
86 session_id: session_id.to_string(),
87 http: default_http_client(),
88 encrypted_reasoning: Arc::new(std::sync::Mutex::new(Vec::new())),
89 })
90 }
91
92 fn build_body(
93 &self,
94 messages: &[Message],
95 system: Option<&str>,
96 tools: &[ToolDefinition],
97 stream: bool,
98 ) -> Value {
99 let encrypted_reasoning = self
100 .encrypted_reasoning
101 .lock()
102 .ok()
103 .map(|value| value.clone())
104 .unwrap_or_default();
105 let mut body = json!({
106 "model": self.model,
107 "instructions": system.unwrap_or(""),
108 "input": convert_messages(messages, &encrypted_reasoning),
109 "tools": convert_tools(tools),
110 "tool_choice": "auto",
111 "parallel_tool_calls": false,
112 "store": false,
113 "stream": stream,
114 "prompt_cache_key": self.session_id,
115 });
116 if let Some(effort) = self.reasoning_effort.as_deref() {
117 body["include"] = json!(["reasoning.encrypted_content"]);
118 body["reasoning"] = json!({"effort": effort, "summary": "auto"});
119 }
120 body
121 }
122}
123
124#[async_trait]
125impl LlmClient for CodexLoginClient {
126 fn model_generation_pool(&self) -> Option<ModelGenerationPool> {
127 ModelGenerationPool::for_account_endpoint(
128 "codex",
129 &self.model,
130 CODEX_BASE,
131 &self.account_id,
132 self.model_generation_concurrency(),
133 )
134 .ok()
135 }
136
137 fn with_active_generation_timeout(
138 &self,
139 timeout: std::time::Duration,
140 ) -> Option<Arc<dyn LlmClient>> {
141 let http = Arc::new(ReqwestHttpClient::with_timeout(timeout).ok()?);
142 Some(Arc::new(Self {
143 access_token: self.access_token.clone(),
144 account_id: self.account_id.clone(),
145 model: self.model.clone(),
146 reasoning_effort: self.reasoning_effort.clone(),
147 session_id: self.session_id.clone(),
148 http,
149 encrypted_reasoning: Arc::new(std::sync::Mutex::new(Vec::new())),
150 }))
151 }
152
153 fn fork_for_session(&self, session_id: &str) -> Option<Arc<dyn LlmClient>> {
154 Some(Arc::new(Self {
155 access_token: self.access_token.clone(),
156 account_id: self.account_id.clone(),
157 model: self.model.clone(),
158 reasoning_effort: self.reasoning_effort.clone(),
159 session_id: session_id.to_string(),
160 http: Arc::clone(&self.http),
161 encrypted_reasoning: Arc::new(std::sync::Mutex::new(Vec::new())),
162 }))
163 }
164
165 async fn complete(
166 &self,
167 messages: &[Message],
168 system: Option<&str>,
169 tools: &[ToolDefinition],
170 ) -> Result<LlmResponse> {
171 let mut rx = self
172 .complete_streaming(messages, system, tools, CancellationToken::new())
173 .await?;
174 while let Some(event) = rx.recv().await {
175 if let StreamEvent::Done(response) = event {
176 return Ok(response);
177 }
178 }
179 Err(anyhow!("codex stream closed before a terminal response"))
180 }
181
182 async fn complete_streaming(
183 &self,
184 messages: &[Message],
185 system: Option<&str>,
186 tools: &[ToolDefinition],
187 cancel_token: CancellationToken,
188 ) -> Result<mpsc::Receiver<StreamEvent>> {
189 let body = self.build_body(messages, system, tools, true);
190 let url = format!("{CODEX_BASE}/responses");
191 let bearer = format!("Bearer {}", self.access_token);
192 let headers = vec![
193 ("Authorization", bearer.as_str()),
194 ("chatgpt-account-id", self.account_id.as_str()),
195 ("OpenAI-Beta", "responses=experimental"),
196 ("originator", ORIGINATOR),
197 ("session_id", self.session_id.as_str()),
198 ("Accept", "text/event-stream"),
199 ("User-Agent", USER_AGENT),
200 ];
201
202 let response = self
203 .http
204 .post_streaming(&url, headers, &body, cancel_token)
205 .await?;
206 if !(200..300).contains(&response.status) {
207 if matches!(response.status, 400..=404) {
212 return Err(anyhow::Error::new(
213 crate::llm::NonRetryableLlmError::from_status(
214 "codex",
215 response.status,
216 response.error_body,
217 ),
218 ));
219 }
220 return Err(anyhow!(
221 "codex /responses HTTP {}: {}",
222 response.status,
223 response.error_body
224 ));
225 }
226
227 let (tx, rx) = mpsc::channel(128);
228 let model = self.model.clone();
229 let encrypted_reasoning = Arc::clone(&self.encrypted_reasoning);
230 let mut stream = response.byte_stream;
231
232 tokio::spawn(async move {
233 use futures::StreamExt;
234
235 let mut buffer = String::new();
236 let mut text = String::new();
237 let mut reasoning = String::new();
238 let mut response_id: Option<String> = None;
239 let mut usage = TokenUsage::default();
240 let mut tool_calls: Vec<(String, PendingToolCall)> = Vec::new();
241 let mut response_encrypted_reasoning: Option<String> = None;
242
243 while let Some(chunk) = stream.next().await {
244 let chunk = match chunk {
245 Ok(chunk) => chunk,
246 Err(_) => break,
247 };
248 buffer.push_str(&String::from_utf8_lossy(&chunk));
249
250 while let Some(end) = buffer.find("\n\n") {
251 let frame: String = buffer.drain(..end).collect();
252 buffer.drain(..2);
253 for event in parse_sse_frame(&frame) {
254 let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
255 match event_type {
256 "response.created" => {
257 response_id = event
258 .pointer("/response/id")
259 .and_then(Value::as_str)
260 .map(str::to_string);
261 }
262 "response.output_text.delta" => {
263 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
264 text.push_str(delta);
265 let _ =
266 tx.send(StreamEvent::TextDelta(delta.to_string())).await;
267 }
268 }
269 "response.reasoning_text.delta"
270 | "response.reasoning_summary_text.delta" => {
271 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
272 reasoning.push_str(delta);
273 let _ = tx
274 .send(StreamEvent::ReasoningDelta(delta.to_string()))
275 .await;
276 }
277 }
278 "response.output_item.added" => {
279 let item = event.get("item");
280 if item.and_then(|i| i.get("type")).and_then(Value::as_str)
281 == Some("function_call")
282 {
283 let id = item_str(item, "id");
284 let call = PendingToolCall {
285 call_id: item_str(item, "call_id"),
286 name: item_str(item, "name"),
287 arguments: String::new(),
288 };
289 let _ = tx
290 .send(StreamEvent::ToolUseStart {
291 id: call.call_id.clone(),
292 name: call.name.clone(),
293 })
294 .await;
295 tool_calls.push((id, call));
296 }
297 }
298 "response.function_call_arguments.delta" => {
299 let item_id =
300 event.get("item_id").and_then(Value::as_str).unwrap_or("");
301 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
302 let tool_id = if let Some((_, call)) =
303 tool_calls.iter_mut().find(|(id, _)| id == item_id)
304 {
305 call.arguments.push_str(delta);
306 Some(call.call_id.clone())
307 } else {
308 None
309 };
310 let _ = tx
311 .send(StreamEvent::ToolUseInputDelta {
312 id: tool_id,
313 delta: delta.to_string(),
314 })
315 .await;
316 }
317 }
318 "response.output_item.done" => {
319 let item = event.get("item");
320 if item
321 .and_then(|item| item.get("type"))
322 .and_then(Value::as_str)
323 == Some("reasoning")
324 {
325 if let Some(value) = item
326 .and_then(|item| item.get("encrypted_content"))
327 .and_then(Value::as_str)
328 {
329 response_encrypted_reasoning = Some(value.to_string());
330 }
331 }
332 if item.and_then(|i| i.get("type")).and_then(Value::as_str)
333 == Some("function_call")
334 {
335 let id = item_str(item, "id");
336 let call = PendingToolCall {
337 call_id: item_str(item, "call_id"),
338 name: item_str(item, "name"),
339 arguments: item_str(item, "arguments"),
340 };
341 if let Some((_, existing)) =
342 tool_calls.iter_mut().find(|(stored, _)| *stored == id)
343 {
344 *existing = call;
345 } else {
346 tool_calls.push((id, call));
347 }
348 }
349 }
350 "response.completed" | "response.incomplete" => {
351 if let Some(response) = event.get("response") {
352 if response_id.is_none() {
353 response_id = response
354 .get("id")
355 .and_then(Value::as_str)
356 .map(str::to_string);
357 }
358 if let Some(raw_usage) = response.get("usage") {
359 usage = parse_usage(raw_usage);
360 }
361 }
362
363 let mut content = Vec::new();
364 if !text.is_empty() {
365 content.push(ContentBlock::Text {
366 text: std::mem::take(&mut text),
367 });
368 }
369 let has_tool_calls = !tool_calls.is_empty();
370 if has_tool_calls {
371 if let Ok(mut stored) = encrypted_reasoning.lock() {
372 stored.push(response_encrypted_reasoning.take());
373 }
374 }
375 for (_, call) in tool_calls.drain(..) {
376 content.push(ContentBlock::ToolUse {
377 id: call.call_id,
378 name: call.name,
379 input: parse_tool_arguments(&call.arguments),
380 });
381 }
382
383 let response = LlmResponse {
384 message: Message {
385 role: "assistant".to_string(),
386 content,
387 reasoning_content: (!reasoning.is_empty())
388 .then(|| std::mem::take(&mut reasoning)),
389 },
390 usage,
391 stop_reason: Some(
392 if has_tool_calls {
393 "tool_calls"
394 } else if event_type == "response.incomplete" {
395 "length"
396 } else {
397 "stop"
398 }
399 .to_string(),
400 ),
401 token_logprobs: Vec::new(),
402 meta: Some(LlmResponseMeta {
403 provider: Some("codex".to_string()),
404 request_model: Some(model.clone()),
405 request_url: Some(url.clone()),
406 response_id: response_id.clone(),
407 ..Default::default()
408 }),
409 };
410 let _ = tx.send(StreamEvent::Done(response)).await;
411 return;
412 }
413 "response.failed" | "error" => return,
414 _ => {}
415 }
416 }
417 }
418 }
419 });
420
421 Ok(rx)
422 }
423
424 fn native_structured_support(&self) -> structured::NativeStructuredSupport {
425 structured::NativeStructuredSupport::None
426 }
427}
428
429#[derive(Debug)]
430struct PendingToolCall {
431 call_id: String,
432 name: String,
433 arguments: String,
434}
435
436fn parse_sse_frame(frame: &str) -> Vec<Value> {
437 let mut data = String::new();
438 for line in frame.lines() {
439 let Some(part) = line
440 .strip_prefix("data: ")
441 .or_else(|| line.strip_prefix("data:"))
442 else {
443 continue;
444 };
445 if !data.is_empty() {
446 data.push('\n');
447 }
448 data.push_str(part.trim());
449 }
450 if data.is_empty() || data == "[DONE]" {
451 return Vec::new();
452 }
453 serde_json::from_str::<Value>(&data)
454 .ok()
455 .into_iter()
456 .collect()
457}
458
459fn parse_usage(usage: &Value) -> TokenUsage {
460 TokenUsage {
461 prompt_tokens: usage
462 .get("input_tokens")
463 .and_then(Value::as_u64)
464 .unwrap_or(0) as usize,
465 completion_tokens: usage
466 .get("output_tokens")
467 .and_then(Value::as_u64)
468 .unwrap_or(0) as usize,
469 total_tokens: usage
470 .get("total_tokens")
471 .and_then(Value::as_u64)
472 .unwrap_or(0) as usize,
473 cache_read_tokens: usage
474 .pointer("/input_tokens_details/cached_tokens")
475 .and_then(Value::as_u64)
476 .map(|value| value as usize),
477 cache_write_tokens: None,
478 }
479}
480
481fn item_str(item: Option<&Value>, key: &str) -> String {
482 item.and_then(|item| item.get(key))
483 .and_then(Value::as_str)
484 .unwrap_or("")
485 .to_string()
486}
487
488fn parse_tool_arguments(arguments: &str) -> Value {
489 if arguments.trim().is_empty() {
490 return json!({});
491 }
492 serde_json::from_str(arguments).unwrap_or_else(|_| json!({}))
493}
494
495fn account_id_from_id_token(jwt: &str) -> Option<String> {
496 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
497
498 let payload = jwt.split('.').nth(1)?;
499 let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
500 let claims: Value = serde_json::from_slice(&bytes).ok()?;
501 claims
502 .pointer("/https:~1~1api.openai.com~1auth/chatgpt_account_id")
503 .and_then(Value::as_str)
504 .map(str::to_string)
505}
506
507fn convert_messages(messages: &[Message], encrypted_reasoning: &[Option<String>]) -> Vec<Value> {
508 let mut out = Vec::new();
509 let mut reasoning = encrypted_reasoning.iter();
510 for message in messages {
511 if message.role == "assistant"
512 && message
513 .content
514 .iter()
515 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
516 {
517 if let Some(Some(encrypted_content)) = reasoning.next() {
518 out.push(json!({
519 "type": "reasoning",
520 "encrypted_content": encrypted_content,
521 }));
522 }
523 }
524 for block in &message.content {
525 match block {
526 ContentBlock::Text { text } => {
527 let text_type = if message.role == "assistant" {
528 "output_text"
529 } else {
530 "input_text"
531 };
532 out.push(json!({
533 "type": "message",
534 "role": message.role,
535 "content": [{ "type": text_type, "text": text }],
536 }));
537 }
538 ContentBlock::ToolUse { id, name, input } => {
539 out.push(json!({
540 "type": "function_call",
541 "name": name,
542 "arguments": input.to_string(),
543 "call_id": id,
544 }));
545 }
546 ContentBlock::ToolResult {
547 tool_use_id,
548 content,
549 ..
550 } => {
551 out.push(json!({
552 "type": "function_call_output",
553 "call_id": tool_use_id,
554 "output": content.as_text(),
555 }));
556 }
557 ContentBlock::Image { source } => {
558 out.push(json!({
559 "type": "message",
560 "role": message.role,
561 "content": [{
562 "type": "input_image",
563 "image_url": format!("data:{};base64,{}", source.media_type, source.data),
564 }],
565 }));
566 }
567 }
568 }
569 }
570 out
571}
572
573fn convert_tools(tools: &[ToolDefinition]) -> Vec<Value> {
574 tools
575 .iter()
576 .map(|tool| {
577 json!({
578 "type": "function",
579 "name": tool.name,
580 "description": tool.description,
581 "parameters": tool.parameters,
582 })
583 })
584 .collect()
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn replays_encrypted_reasoning_in_tool_turn_order() {
593 let messages = vec![
594 Message::user("inspect the workspace"),
595 Message {
596 role: "assistant".to_string(),
597 content: vec![ContentBlock::ToolUse {
598 id: "call-1".to_string(),
599 name: "bash".to_string(),
600 input: json!({"command": "pwd"}),
601 }],
602 reasoning_content: None,
603 },
604 Message::tool_result("call-1", "workspace", false),
605 Message {
606 role: "assistant".to_string(),
607 content: vec![ContentBlock::ToolUse {
608 id: "call-2".to_string(),
609 name: "read".to_string(),
610 input: json!({"path": "README.md"}),
611 }],
612 reasoning_content: None,
613 },
614 Message::tool_result("call-2", "contents", false),
615 ];
616
617 let converted = convert_messages(
618 &messages,
619 &[
620 Some("encrypted-1".to_string()),
621 Some("encrypted-2".to_string()),
622 ],
623 );
624 let reasoning = converted
625 .iter()
626 .filter(|item| item.get("type").and_then(Value::as_str) == Some("reasoning"))
627 .map(|item| item["encrypted_content"].as_str().unwrap())
628 .collect::<Vec<_>>();
629 assert_eq!(reasoning, ["encrypted-1", "encrypted-2"]);
630 }
631}