Skip to main content

a3s_code_core/llm/
codex_login.rs

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
19/// Codex account-backed Responses client used by local evaluation adapters.
20///
21/// The client reads a short-lived auth snapshot supplied by the caller and never
22/// writes credentials to the workspace or session store.
23pub 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    /// One encrypted reasoning item per assistant tool-call turn. The
31    /// Responses API requires the items to be replayed in their original
32    /// order when a tool result is followed by another model request.
33    encrypted_reasoning: Arc<std::sync::Mutex<Vec<Option<String>>>>,
34}
35
36impl CodexLoginClient {
37    /// Load the local Codex login used by developer-side evaluations.
38    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            // Authentication, authorization, request-shape, and billing
208            // failures cannot be repaired by replaying the same Responses
209            // request. Preserve the status as a typed terminal error so the
210            // outer Agent loop does not turn a 402/401 into repeated calls.
211            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}