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 transcript_text: None,
390 transcript_visibility: Default::default(),
391 },
392 usage,
393 stop_reason: Some(
394 if has_tool_calls {
395 "tool_calls"
396 } else if event_type == "response.incomplete" {
397 "length"
398 } else {
399 "stop"
400 }
401 .to_string(),
402 ),
403 token_logprobs: Vec::new(),
404 meta: Some(LlmResponseMeta {
405 provider: Some("codex".to_string()),
406 request_model: Some(model.clone()),
407 request_url: Some(url.clone()),
408 response_id: response_id.clone(),
409 ..Default::default()
410 }),
411 };
412 let _ = tx.send(StreamEvent::Done(response)).await;
413 return;
414 }
415 "response.failed" | "error" => return,
416 _ => {}
417 }
418 }
419 }
420 }
421 });
422
423 Ok(rx)
424 }
425
426 fn native_structured_support(&self) -> structured::NativeStructuredSupport {
427 structured::NativeStructuredSupport::None
428 }
429}
430
431#[derive(Debug)]
432struct PendingToolCall {
433 call_id: String,
434 name: String,
435 arguments: String,
436}
437
438fn parse_sse_frame(frame: &str) -> Vec<Value> {
439 let mut data = String::new();
440 for line in frame.lines() {
441 let Some(part) = line
442 .strip_prefix("data: ")
443 .or_else(|| line.strip_prefix("data:"))
444 else {
445 continue;
446 };
447 if !data.is_empty() {
448 data.push('\n');
449 }
450 data.push_str(part.trim());
451 }
452 if data.is_empty() || data == "[DONE]" {
453 return Vec::new();
454 }
455 serde_json::from_str::<Value>(&data)
456 .ok()
457 .into_iter()
458 .collect()
459}
460
461fn parse_usage(usage: &Value) -> TokenUsage {
462 TokenUsage {
463 prompt_tokens: usage
464 .get("input_tokens")
465 .and_then(Value::as_u64)
466 .unwrap_or(0) as usize,
467 completion_tokens: usage
468 .get("output_tokens")
469 .and_then(Value::as_u64)
470 .unwrap_or(0) as usize,
471 total_tokens: usage
472 .get("total_tokens")
473 .and_then(Value::as_u64)
474 .unwrap_or(0) as usize,
475 cache_read_tokens: usage
476 .pointer("/input_tokens_details/cached_tokens")
477 .and_then(Value::as_u64)
478 .map(|value| value as usize),
479 cache_write_tokens: None,
480 }
481}
482
483fn item_str(item: Option<&Value>, key: &str) -> String {
484 item.and_then(|item| item.get(key))
485 .and_then(Value::as_str)
486 .unwrap_or("")
487 .to_string()
488}
489
490fn parse_tool_arguments(arguments: &str) -> Value {
491 if arguments.trim().is_empty() {
492 return json!({});
493 }
494 serde_json::from_str(arguments).unwrap_or_else(|_| json!({}))
495}
496
497fn account_id_from_id_token(jwt: &str) -> Option<String> {
498 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
499
500 let payload = jwt.split('.').nth(1)?;
501 let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
502 let claims: Value = serde_json::from_slice(&bytes).ok()?;
503 claims
504 .pointer("/https:~1~1api.openai.com~1auth/chatgpt_account_id")
505 .and_then(Value::as_str)
506 .map(str::to_string)
507}
508
509fn convert_messages(messages: &[Message], encrypted_reasoning: &[Option<String>]) -> Vec<Value> {
510 let mut out = Vec::new();
511 let mut reasoning = encrypted_reasoning.iter();
512 for message in messages {
513 if message.role == "assistant"
514 && message
515 .content
516 .iter()
517 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
518 {
519 if let Some(Some(encrypted_content)) = reasoning.next() {
520 out.push(json!({
521 "type": "reasoning",
522 "encrypted_content": encrypted_content,
523 }));
524 }
525 }
526 for block in &message.content {
527 match block {
528 ContentBlock::Text { text } => {
529 let text_type = if message.role == "assistant" {
530 "output_text"
531 } else {
532 "input_text"
533 };
534 out.push(json!({
535 "type": "message",
536 "role": message.role,
537 "content": [{ "type": text_type, "text": text }],
538 }));
539 }
540 ContentBlock::ToolUse { id, name, input } => {
541 out.push(json!({
542 "type": "function_call",
543 "name": name,
544 "arguments": input.to_string(),
545 "call_id": id,
546 }));
547 }
548 ContentBlock::ToolResult {
549 tool_use_id,
550 content,
551 ..
552 } => {
553 out.push(json!({
554 "type": "function_call_output",
555 "call_id": tool_use_id,
556 "output": content.as_text(),
557 }));
558 }
559 ContentBlock::Image { source } => {
560 out.push(json!({
561 "type": "message",
562 "role": message.role,
563 "content": [{
564 "type": "input_image",
565 "image_url": format!("data:{};base64,{}", source.media_type, source.data),
566 }],
567 }));
568 }
569 }
570 }
571 }
572 out
573}
574
575fn convert_tools(tools: &[ToolDefinition]) -> Vec<Value> {
576 tools
577 .iter()
578 .map(|tool| {
579 json!({
580 "type": "function",
581 "name": tool.name,
582 "description": tool.description,
583 "parameters": tool.parameters,
584 })
585 })
586 .collect()
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 #[test]
594 fn replays_encrypted_reasoning_in_tool_turn_order() {
595 let messages = vec![
596 Message::user("inspect the workspace"),
597 Message {
598 role: "assistant".to_string(),
599 content: vec![ContentBlock::ToolUse {
600 id: "call-1".to_string(),
601 name: "bash".to_string(),
602 input: json!({"command": "pwd"}),
603 }],
604 reasoning_content: None,
605 transcript_text: None,
606 transcript_visibility: Default::default(),
607 },
608 Message::tool_result("call-1", "workspace", false),
609 Message {
610 role: "assistant".to_string(),
611 content: vec![ContentBlock::ToolUse {
612 id: "call-2".to_string(),
613 name: "read".to_string(),
614 input: json!({"path": "README.md"}),
615 }],
616 reasoning_content: None,
617 transcript_text: None,
618 transcript_visibility: Default::default(),
619 },
620 Message::tool_result("call-2", "contents", false),
621 ];
622
623 let converted = convert_messages(
624 &messages,
625 &[
626 Some("encrypted-1".to_string()),
627 Some("encrypted-2".to_string()),
628 ],
629 );
630 let reasoning = converted
631 .iter()
632 .filter(|item| item.get("type").and_then(Value::as_str) == Some("reasoning"))
633 .map(|item| item["encrypted_content"].as_str().unwrap())
634 .collect::<Vec<_>>();
635 assert_eq!(reasoning, ["encrypted-1", "encrypted-2"]);
636 }
637}