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                                    },
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}