Skip to main content

sac/model/
chatgpt_codex.rs

1use super::*;
2use anyhow::Context;
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use base64::Engine;
5use reqwest::header;
6use reqwest::StatusCode;
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9use std::fmt;
10use std::fs::{self, File, OpenOptions};
11use std::io;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::Instant;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
18const DEVICE_USER_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode";
19const DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token";
20const DEVICE_VERIFICATION_URL: &str = "https://auth.openai.com/codex/device";
21const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback";
22const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
23const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke";
24const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
25const DEFAULT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
26const ORIGINATOR: &str = "codex_cli_rs";
27const AUTH_TYPE: &str = "chatgpt-codex";
28const DEFAULT_EXPIRES_IN_SECS: u64 = 3600;
29const REFRESH_SKEW_MS: u64 = 300_000;
30const DEVICE_TIMEOUT_SECS: u64 = 15 * 60;
31const OAUTH_SCOPE: &str = "openid profile email offline_access api.connectors.read api.connectors.invoke";
32const DEFAULT_SERVER_PORT: u16 = 1455;
33const FALLBACK_SERVER_PORT: u16 = 1457;
34const REVOKE_TIMEOUT_SECS: u64 = 10;
35const CODEX_API_KEY_ENV: &str = "CODEX_API_KEY";
36const CODEX_ACCESS_TOKEN_ENV: &str = "CODEX_ACCESS_TOKEN";
37const AUTH_TYPE_API_KEY: &str = "api-key";
38const AUTH_TYPE_PAT: &str = "personal-access-token";
39const OPENAI_API_BASE_URL: &str = "https://api.openai.com/v1";
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42struct StoredCodexAuth {
43    #[serde(rename = "type")]
44    auth_type: String,
45    access: String,
46    #[serde(default)]
47    refresh: String,
48    #[serde(default)]
49    expires_at_ms: u64,
50    #[serde(default)]
51    account_id: String,
52}
53
54#[derive(Debug, Deserialize)]
55struct TokenResponse {
56    id_token: Option<String>,
57    access_token: String,
58    refresh_token: String,
59    expires_in: Option<u64>,
60}
61
62#[derive(Debug)]
63struct DeviceCode {
64    device_auth_id: String,
65    user_code: String,
66    interval_secs: u64,
67}
68
69#[derive(Debug)]
70struct AuthorizationCode {
71    code: String,
72    verifier: String,
73}
74
75#[derive(Debug)]
76struct CodexRequestError {
77    status: Option<StatusCode>,
78    message: String,
79}
80
81impl fmt::Display for CodexRequestError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(&self.message)
84    }
85}
86
87impl std::error::Error for CodexRequestError {}
88
89struct PkceCodes {
90    code_verifier: String,
91    code_challenge: String,
92}
93
94fn generate_pkce() -> PkceCodes {
95    use rand::RngExt;
96    let mut bytes = [0u8; 64];
97    rand::rng().fill(&mut bytes);
98    let code_verifier = URL_SAFE_NO_PAD.encode(bytes);
99    let digest = Sha256::digest(code_verifier.as_bytes());
100    let code_challenge = URL_SAFE_NO_PAD.encode(digest);
101    PkceCodes {
102        code_verifier,
103        code_challenge,
104    }
105}
106
107fn generate_state() -> String {
108    use rand::RngExt;
109    let mut bytes = [0u8; 32];
110    rand::rng().fill(&mut bytes);
111    URL_SAFE_NO_PAD.encode(bytes)
112}
113
114fn build_authorize_url(redirect_uri: &str, pkce: &PkceCodes, state: &str) -> String {
115    let params = [
116        ("response_type", "code"),
117        ("client_id", CLIENT_ID),
118        ("redirect_uri", redirect_uri),
119        ("scope", OAUTH_SCOPE),
120        ("code_challenge", &pkce.code_challenge),
121        ("code_challenge_method", "S256"),
122        ("id_token_add_organizations", "true"),
123        ("codex_cli_simplified_flow", "true"),
124        ("state", state),
125        ("originator", ORIGINATOR),
126    ];
127    let qs = params
128        .iter()
129        .map(|(k, v)| format!("{k}={}", urlencoding::encode(v)))
130        .collect::<Vec<_>>()
131        .join("&");
132    format!("{AUTHORIZE_URL}?{qs}")
133}
134
135fn bind_server() -> Result<tiny_http::Server> {
136    tiny_http::Server::http(format!("127.0.0.1:{DEFAULT_SERVER_PORT}"))
137        .or_else(|_| tiny_http::Server::http(format!("127.0.0.1:{FALLBACK_SERVER_PORT}")))
138        .map_err(|e| anyhow!("failed to start local auth server: {e}"))
139}
140
141fn wait_for_callback(server: &Arc<tiny_http::Server>, expected_state: &str) -> Result<String> {
142    loop {
143        let request = server.recv().map_err(|e| anyhow!("server recv error: {e}"))?;
144        let url_str = format!("http://localhost{}", request.url());
145        let parsed = url::Url::parse(&url_str).context("failed to parse callback URL")?;
146
147        if !parsed.path().starts_with("/auth/callback") {
148            let response = tiny_http::Response::from_string("Not found")
149                .with_status_code(tiny_http::StatusCode(404));
150            let _ = request.respond(response);
151            continue;
152        }
153
154        let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
155
156        if let Some(error) = params.get("error") {
157            let desc = params
158                .get("error_description")
159                .map(|s| s.to_string())
160                .unwrap_or_default();
161            let html = format!(
162                "<html><body><h2>Authentication failed</h2><p>{error}: {desc}</p></body></html>"
163            );
164            let response = tiny_http::Response::from_string(html)
165                .with_header("Content-Type: text/html".parse::<tiny_http::Header>().unwrap())
166                .with_status_code(tiny_http::StatusCode(400));
167            let _ = request.respond(response);
168            return Err(anyhow!("OAuth error: {error} - {desc}"));
169        }
170
171        let state = params
172            .get("state")
173            .ok_or_else(|| anyhow!("callback missing state parameter"))?;
174        if state.as_ref() != expected_state {
175            let response = tiny_http::Response::from_string("Invalid state")
176                .with_status_code(tiny_http::StatusCode(400));
177            let _ = request.respond(response);
178            continue;
179        }
180
181        let code = params
182            .get("code")
183            .ok_or_else(|| anyhow!("callback missing code parameter"))?
184            .to_string();
185
186        let html = "<html><body><h2>Authentication successful</h2><p>You can close this tab and return to the terminal.</p></body></html>";
187        let response = tiny_http::Response::from_string(html)
188            .with_header("Content-Type: text/html".parse::<tiny_http::Header>().unwrap());
189        let _ = request.respond(response);
190
191        return Ok(code);
192    }
193}
194
195async fn exchange_code_pkce(
196    client: &Client,
197    redirect_uri: &str,
198    pkce: &PkceCodes,
199    code: &str,
200) -> Result<TokenResponse> {
201    let response = client
202        .post(TOKEN_URL)
203        .header("Content-Type", "application/x-www-form-urlencoded")
204        .form(&[
205            ("grant_type", "authorization_code"),
206            ("code", code),
207            ("redirect_uri", redirect_uri),
208            ("client_id", CLIENT_ID),
209            ("code_verifier", pkce.code_verifier.as_str()),
210        ])
211        .send()
212        .await
213        .context("failed to exchange authorization code")?;
214
215    parse_token_response(response, "browser token exchange").await
216}
217
218async fn browser_login() -> Result<()> {
219    let pkce = generate_pkce();
220    let state = generate_state();
221    let server = Arc::new(bind_server()?);
222    let addr = server
223        .server_addr()
224        .to_ip()
225        .ok_or_else(|| anyhow!("unable to determine server port"))?;
226    let redirect_uri = format!("http://localhost:{}/auth/callback", addr.port());
227    let auth_url = build_authorize_url(&redirect_uri, &pkce, &state);
228
229    if let Err(e) = webbrowser::open(&auth_url) {
230        eprintln!("Failed to open browser: {e}");
231    }
232    eprintln!("If your browser did not open, navigate to:");
233    eprintln!("{auth_url}");
234    eprintln!();
235    eprintln!("Waiting for authentication...");
236
237    let server_clone = Arc::clone(&server);
238    let state_clone = state.clone();
239    let code =
240        tokio::task::spawn_blocking(move || wait_for_callback(&server_clone, &state_clone))
241            .await
242            .context("callback handler panicked")??;
243
244    let client = Client::new();
245    let tokens = exchange_code_pkce(&client, &redirect_uri, &pkce, &code).await?;
246    let auth = auth_from_token_response(tokens, None)?;
247    with_auth_lock(|| write_auth_file(&auth))?;
248
249    println!("Codex auth saved.");
250    println!("account: {}", auth.account_id);
251    println!("path: {}", auth_file_path()?.display());
252    Ok(())
253}
254
255async fn device_code_login() -> Result<()> {
256    let client = Client::new();
257    let device = request_device_code(&client).await?;
258
259    println!("Open this URL in a browser:");
260    println!("{DEVICE_VERIFICATION_URL}");
261    println!();
262    println!("Enter this code:");
263    println!("{}", device.user_code);
264    println!();
265    println!("Waiting for authorization...");
266
267    let code = poll_device_code(&client, &device).await?;
268    let tokens = exchange_authorization_code(&client, &code).await?;
269    let auth = auth_from_token_response(tokens, None)?;
270    with_auth_lock(|| write_auth_file(&auth))?;
271
272    println!("Codex auth saved.");
273    println!("account: {}", auth.account_id);
274    println!("path: {}", auth_file_path()?.display());
275    Ok(())
276}
277
278pub async fn codex_auth_login(headless: bool) -> Result<()> {
279    if headless {
280        return device_code_login().await;
281    }
282    match browser_login().await {
283        Ok(()) => Ok(()),
284        Err(e) => {
285            eprintln!("Browser login failed: {e}");
286            eprintln!("Falling back to device code flow...");
287            device_code_login().await
288        }
289    }
290}
291
292pub fn codex_auth_login_api_key(api_key: &str) -> Result<()> {
293    let key = api_key.trim();
294    if key.is_empty() {
295        return Err(anyhow!("API key is empty"));
296    }
297    let auth = StoredCodexAuth {
298        auth_type: AUTH_TYPE_API_KEY.to_string(),
299        access: key.to_string(),
300        refresh: String::new(),
301        expires_at_ms: 0,
302        account_id: String::new(),
303    };
304    with_auth_lock(|| write_auth_file(&auth))?;
305    println!("API key auth saved.");
306    println!("path: {}", auth_file_path()?.display());
307    Ok(())
308}
309
310pub fn codex_auth_login_access_token(token: &str) -> Result<()> {
311    let token = token.trim();
312    if token.is_empty() {
313        return Err(anyhow!("access token is empty"));
314    }
315    let auth = StoredCodexAuth {
316        auth_type: AUTH_TYPE_PAT.to_string(),
317        access: token.to_string(),
318        refresh: String::new(),
319        expires_at_ms: 0,
320        account_id: String::new(),
321    };
322    with_auth_lock(|| write_auth_file(&auth))?;
323    println!("Access token auth saved.");
324    println!("path: {}", auth_file_path()?.display());
325    Ok(())
326}
327
328async fn revoke_token(client: &Client, token: &str, token_type_hint: &str) -> Result<()> {
329    let mut body = json!({
330        "token": token,
331        "token_type_hint": token_type_hint,
332    });
333    if token_type_hint == "refresh_token" {
334        body["client_id"] = Value::String(CLIENT_ID.to_string());
335    }
336
337    let result = client
338        .post(REVOKE_TOKEN_URL)
339        .header("Content-Type", "application/json")
340        .header("User-Agent", codex_user_agent())
341        .timeout(std::time::Duration::from_secs(REVOKE_TIMEOUT_SECS))
342        .json(&body)
343        .send()
344        .await;
345
346    match result {
347        Ok(resp) if resp.status().is_success() => {
348            tracing::info!("token revoked successfully");
349            Ok(())
350        }
351        Ok(resp) => {
352            let status = resp.status();
353            let text = resp.text().await.unwrap_or_default();
354            tracing::warn!("token revocation returned HTTP {}: {}", status, truncate(&text));
355            Ok(())
356        }
357        Err(e) => {
358            tracing::warn!("token revocation request failed: {e}");
359            Ok(())
360        }
361    }
362}
363
364pub async fn codex_auth_logout() -> Result<()> {
365    let path = auth_file_path()?;
366
367    if let Ok(Some(auth)) = with_auth_lock(|| read_auth_file_optional()) {
368        if auth.auth_type == AUTH_TYPE && !auth.refresh.is_empty() {
369            let client = Client::new();
370            let _ = revoke_token(&client, &auth.refresh, "refresh_token").await;
371        }
372    }
373
374    let removed = with_auth_lock(|| match fs::remove_file(&path) {
375        Ok(()) => Ok(true),
376        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
377        Err(error) => Err(error).with_context(|| format!("failed to remove {}", path.display())),
378    })?;
379
380    if removed {
381        println!("Codex auth removed.");
382    } else {
383        println!("No Codex auth found.");
384    }
385    println!("path: {}", path.display());
386    Ok(())
387}
388
389pub fn codex_auth_status() -> Result<()> {
390    let path = auth_file_path()?;
391
392    if let Ok(key) = std::env::var(CODEX_API_KEY_ENV) {
393        if !key.is_empty() {
394            println!("Codex auth: {} (via {CODEX_API_KEY_ENV} env)", AUTH_TYPE_API_KEY);
395            return Ok(());
396        }
397    }
398    if let Ok(token) = std::env::var(CODEX_ACCESS_TOKEN_ENV) {
399        if !token.is_empty() {
400            let mode = if token.starts_with("at-") { AUTH_TYPE_PAT } else { "access-token" };
401            println!("Codex auth: {mode} (via {CODEX_ACCESS_TOKEN_ENV} env)");
402            return Ok(());
403        }
404    }
405
406    let auth = with_auth_lock(|| read_auth_file_optional())?;
407    match auth {
408        Some(auth) => {
409            println!("Codex auth: signed in ({})", auth.auth_type);
410            if !auth.account_id.is_empty() {
411                println!("account: {}", auth.account_id);
412            }
413            if auth.expires_at_ms > 0 {
414                println!("expires: {}", expiry_status(auth.expires_at_ms));
415            }
416            println!("path: {}", path.display());
417        }
418        None => {
419            println!("Codex auth: not signed in");
420            println!("path: {}", path.display());
421        }
422    }
423    Ok(())
424}
425
426pub async fn send_responses(
427    client: &Client,
428    base_url: &str,
429    model: &str,
430    reasoning_effort: Option<&ReasoningEffort>,
431    reasoning_summary: Option<&ReasoningSummary>,
432    reasoning_context: Option<&ReasoningContext>,
433    messages: Vec<Message>,
434    tools: Vec<ToolDefinition>,
435) -> Result<ModelTurnResponse> {
436    let auth = fresh_auth(client).await?;
437    let url = if auth.auth_type == AUTH_TYPE_API_KEY {
438        format!("{}/responses", OPENAI_API_BASE_URL.trim_end_matches('/'))
439    } else {
440        codex_responses_url(base_url)
441    };
442    let request = codex_responses_request(model, reasoning_effort, reasoning_summary, reasoning_context, &messages, &tools);
443    let started = Instant::now();
444    tracing::info!(
445        backend = ?BackendKind::ChatGptCodexResponses,
446        model = %model,
447        auth_type = %auth.auth_type,
448        reasoning_effort = ?reasoning_effort,
449        message_count = messages.len(),
450        tool_count = tools.len(),
451        request_bytes = serde_json::to_vec(&request)?.len(),
452        "starting codex responses turn"
453    );
454
455    match post_codex_json_with_retry(client, &url, &request, &auth).await {
456        Ok(value) => {
457            let parsed = parse_openai_responses_response(&value, &url)?;
458            tracing::info!(
459                finish_reason = ?parsed.finish_reason,
460                has_text = parsed.assistant.content.is_some(),
461                tool_call_count = parsed
462                    .assistant
463                    .tool_calls
464                    .as_ref()
465                    .map(|calls| calls.len())
466                    .unwrap_or(0),
467                latency_ms = started.elapsed().as_millis() as u64,
468                "codex responses turn completed"
469            );
470            Ok(parsed)
471        }
472        Err(error) if error.status == Some(StatusCode::UNAUTHORIZED) => {
473            tracing::warn!("codex responses request returned 401; forcing auth refresh");
474            let refreshed = force_refresh_auth(client).await?;
475            let value = post_codex_json_with_retry(client, &url, &request, &refreshed)
476                .await
477                .map_err(anyhow::Error::new)?;
478            let parsed = parse_openai_responses_response(&value, &url)?;
479            tracing::info!(
480                finish_reason = ?parsed.finish_reason,
481                has_text = parsed.assistant.content.is_some(),
482                tool_call_count = parsed
483                    .assistant
484                    .tool_calls
485                    .as_ref()
486                    .map(|calls| calls.len())
487                    .unwrap_or(0),
488                latency_ms = started.elapsed().as_millis() as u64,
489                refreshed_auth = true,
490                "codex responses turn completed after auth refresh"
491            );
492            Ok(parsed)
493        }
494        Err(error) => Err(anyhow::Error::new(error)),
495    }
496}
497
498async fn request_device_code(client: &Client) -> Result<DeviceCode> {
499    let response = client
500        .post(DEVICE_USER_CODE_URL)
501        .header("Content-Type", "application/json")
502        .header("User-Agent", codex_user_agent())
503        .json(&json!({ "client_id": CLIENT_ID }))
504        .send()
505        .await
506        .context("failed to request Codex device code")?;
507
508    let status = response.status();
509    let body = response
510        .text()
511        .await
512        .context("failed to read Codex device-code response")?;
513    if !status.is_success() {
514        return Err(anyhow!(
515            "Codex device-code request failed with HTTP {}: {}",
516            status.as_u16(),
517            truncate(&body)
518        ));
519    }
520
521    let value: Value =
522        serde_json::from_str(&body).context("failed to parse Codex device-code response")?;
523    let device_auth_id = value
524        .get("device_auth_id")
525        .and_then(Value::as_str)
526        .ok_or_else(|| anyhow!("Codex device-code response did not include device_auth_id"))?
527        .to_string();
528    let user_code = value
529        .get("user_code")
530        .or_else(|| value.get("usercode"))
531        .and_then(Value::as_str)
532        .ok_or_else(|| anyhow!("Codex device-code response did not include user_code"))?
533        .to_string();
534    let interval_secs = interval_secs(value.get("interval")).unwrap_or(5).max(1);
535
536    Ok(DeviceCode {
537        device_auth_id,
538        user_code,
539        interval_secs,
540    })
541}
542
543async fn poll_device_code(client: &Client, device: &DeviceCode) -> Result<AuthorizationCode> {
544    let started = now_ms();
545    loop {
546        let response = client
547            .post(DEVICE_TOKEN_URL)
548            .header("Content-Type", "application/json")
549            .header("User-Agent", codex_user_agent())
550            .json(&json!({
551                "device_auth_id": device.device_auth_id,
552                "user_code": device.user_code,
553            }))
554            .send()
555            .await
556            .context("failed to poll Codex device authorization")?;
557
558        let status = response.status();
559        let body = response
560            .text()
561            .await
562            .context("failed to read Codex device authorization response")?;
563
564        if status.is_success() {
565            let value: Value = serde_json::from_str(&body)
566                .context("failed to parse Codex device authorization response")?;
567            let code = value
568                .get("authorization_code")
569                .and_then(Value::as_str)
570                .ok_or_else(|| {
571                    anyhow!(
572                        "Codex device authorization response did not include authorization_code"
573                    )
574                })?
575                .to_string();
576            let verifier = value
577                .get("code_verifier")
578                .and_then(Value::as_str)
579                .ok_or_else(|| {
580                    anyhow!("Codex device authorization response did not include code_verifier")
581                })?
582                .to_string();
583            return Ok(AuthorizationCode { code, verifier });
584        }
585
586        if status != StatusCode::FORBIDDEN && status != StatusCode::NOT_FOUND {
587            return Err(anyhow!(
588                "Codex device authorization failed with HTTP {}: {}",
589                status.as_u16(),
590                truncate(&body)
591            ));
592        }
593
594        if now_ms().saturating_sub(started) >= DEVICE_TIMEOUT_SECS * 1000 {
595            return Err(anyhow!(
596                "Codex device authorization timed out after 15 minutes"
597            ));
598        }
599
600        sleep(Duration::from_secs(device.interval_secs)).await;
601    }
602}
603
604async fn exchange_authorization_code(
605    client: &Client,
606    code: &AuthorizationCode,
607) -> Result<TokenResponse> {
608    let response = client
609        .post(TOKEN_URL)
610        .header("Content-Type", "application/x-www-form-urlencoded")
611        .form(&[
612            ("grant_type", "authorization_code"),
613            ("code", code.code.as_str()),
614            ("redirect_uri", DEVICE_REDIRECT_URI),
615            ("client_id", CLIENT_ID),
616            ("code_verifier", code.verifier.as_str()),
617        ])
618        .send()
619        .await
620        .context("failed to exchange Codex authorization code")?;
621
622    parse_token_response(response, "Codex token exchange").await
623}
624
625async fn refresh_access_token(client: &Client, refresh_token: &str) -> Result<TokenResponse> {
626    let response = client
627        .post(TOKEN_URL)
628        .header("Content-Type", "application/json")
629        .header("User-Agent", codex_user_agent())
630        .json(&json!({
631            "client_id": CLIENT_ID,
632            "grant_type": "refresh_token",
633            "refresh_token": refresh_token,
634        }))
635        .send()
636        .await
637        .context("failed to refresh Codex access token")?;
638
639    parse_token_response(response, "Codex token refresh").await
640}
641
642async fn parse_token_response(response: reqwest::Response, label: &str) -> Result<TokenResponse> {
643    let status = response.status();
644    let body = response
645        .text()
646        .await
647        .with_context(|| format!("failed to read {label} response"))?;
648    if !status.is_success() {
649        return Err(anyhow!(
650            "{label} failed with HTTP {}: {}",
651            status.as_u16(),
652            truncate(&body)
653        ));
654    }
655    serde_json::from_str(&body).with_context(|| format!("failed to parse {label} response"))
656}
657
658fn resolve_auth_from_env() -> Option<StoredCodexAuth> {
659    if let Ok(key) = std::env::var(CODEX_API_KEY_ENV) {
660        if !key.is_empty() {
661            return Some(StoredCodexAuth {
662                auth_type: AUTH_TYPE_API_KEY.to_string(),
663                access: key,
664                refresh: String::new(),
665                expires_at_ms: 0,
666                account_id: String::new(),
667            });
668        }
669    }
670    if let Ok(token) = std::env::var(CODEX_ACCESS_TOKEN_ENV) {
671        if !token.is_empty() {
672            let auth_type = if token.starts_with("at-") {
673                AUTH_TYPE_PAT
674            } else {
675                AUTH_TYPE
676            };
677            return Some(StoredCodexAuth {
678                auth_type: auth_type.to_string(),
679                access: token,
680                refresh: String::new(),
681                expires_at_ms: 0,
682                account_id: String::new(),
683            });
684        }
685    }
686    None
687}
688
689fn auth_needs_refresh(auth: &StoredCodexAuth) -> bool {
690    auth.auth_type == AUTH_TYPE
691        && auth.expires_at_ms > 0
692        && !auth.refresh.is_empty()
693}
694
695async fn fresh_auth(client: &Client) -> Result<StoredCodexAuth> {
696    if let Some(auth) = resolve_auth_from_env() {
697        return Ok(auth);
698    }
699    let _lock = acquire_auth_lock()?;
700    let auth = read_auth_file()?;
701    if !auth_needs_refresh(&auth) || auth.expires_at_ms > now_ms().saturating_add(REFRESH_SKEW_MS) {
702        tracing::debug!(
703            remaining_ms = auth.expires_at_ms.saturating_sub(now_ms()),
704            "reusing existing codex auth token"
705        );
706        return Ok(auth);
707    }
708    tracing::info!(
709        remaining_ms = auth.expires_at_ms.saturating_sub(now_ms()),
710        refresh_trigger = "expiry",
711        "refreshing codex auth because token is near expiry"
712    );
713    refresh_and_store_auth(client, auth).await
714}
715
716async fn force_refresh_auth(client: &Client) -> Result<StoredCodexAuth> {
717    if let Some(auth) = resolve_auth_from_env() {
718        return Err(anyhow!(
719            "401 Unauthorized with {} auth (env var). Check your key/token.",
720            auth.auth_type
721        ));
722    }
723    let _lock = acquire_auth_lock()?;
724    let auth = read_auth_file()?;
725    if !auth_needs_refresh(&auth) {
726        return Err(anyhow!(
727            "401 Unauthorized with {} auth. Re-run `sac codex-auth login` to re-authenticate.",
728            auth.auth_type
729        ));
730    }
731    tracing::warn!(
732        refresh_trigger = "401",
733        "forcing codex auth refresh after unauthorized response"
734    );
735    refresh_and_store_auth(client, auth).await
736}
737
738async fn refresh_and_store_auth(
739    client: &Client,
740    current: StoredCodexAuth,
741) -> Result<StoredCodexAuth> {
742    let started = Instant::now();
743    let tokens = refresh_access_token(client, &current.refresh).await?;
744    let refreshed = auth_from_token_response(tokens, Some(&current.account_id))?;
745    write_auth_file(&refreshed)?;
746    tracing::info!(
747        latency_ms = started.elapsed().as_millis() as u64,
748        "codex auth refresh persisted"
749    );
750    Ok(refreshed)
751}
752
753fn auth_from_token_response(
754    response: TokenResponse,
755    fallback_account_id: Option<&str>,
756) -> Result<StoredCodexAuth> {
757    let account_id = response
758        .id_token
759        .as_deref()
760        .and_then(extract_account_id)
761        .or_else(|| extract_account_id(&response.access_token))
762        .or(fallback_account_id.map(str::to_string))
763        .ok_or_else(|| anyhow!("Codex token response did not include a ChatGPT account id"))?;
764    let expires_in = response.expires_in.unwrap_or(DEFAULT_EXPIRES_IN_SECS);
765    Ok(StoredCodexAuth {
766        auth_type: AUTH_TYPE.to_string(),
767        access: response.access_token,
768        refresh: response.refresh_token,
769        expires_at_ms: now_ms().saturating_add(expires_in.saturating_mul(1000)),
770        account_id,
771    })
772}
773
774fn codex_responses_request(
775    model: &str,
776    reasoning_effort: Option<&ReasoningEffort>,
777    reasoning_summary: Option<&ReasoningSummary>,
778    reasoning_context: Option<&ReasoningContext>,
779    messages: &[Message],
780    tools: &[ToolDefinition],
781) -> Value {
782    let (instructions, input) = codex_instructions_and_input(messages);
783    let mut request = json!({
784        "model": model,
785        "input": input,
786        "store": false,
787        "stream": true,
788        "text": {
789            "verbosity": "low",
790        },
791        "tool_choice": "auto",
792        "parallel_tool_calls": true,
793    });
794
795    if let Some(instructions) = instructions {
796        request["instructions"] = Value::String(instructions);
797    }
798
799    if !tools.is_empty() {
800        request["tools"] = Value::Array(
801            tools
802                .iter()
803                .map(openai_responses_tool_to_value)
804                .collect::<Vec<_>>(),
805        );
806    }
807
808    if let Some(effort) = reasoning_effort {
809        let mut reasoning = json!({
810            "effort": effort.as_str(),
811        });
812        if let Some(summary) = reasoning_summary {
813            reasoning["summary"] = json!(summary.as_str());
814        }
815        if let Some(context) = reasoning_context {
816            reasoning["context"] = json!(context.as_str());
817        }
818        request["reasoning"] = reasoning;
819        request["include"] = json!(["reasoning.encrypted_content"]);
820    }
821
822    request
823}
824
825fn codex_instructions_and_input(messages: &[Message]) -> (Option<String>, Vec<Value>) {
826    let mut instructions = Vec::new();
827    let mut input_messages = Vec::new();
828
829    for message in messages {
830        match message {
831            Message::System { content } => {
832                if !content.trim().is_empty() {
833                    instructions.push(content.clone());
834                }
835            }
836            _ => input_messages.push(message.clone()),
837        }
838    }
839
840    let instructions = if instructions.is_empty() {
841        None
842    } else {
843        Some(instructions.join("\n\n"))
844    };
845    (instructions, responses_input_items(&input_messages))
846}
847
848async fn post_codex_json_with_retry(
849    client: &Client,
850    url: &str,
851    body: &Value,
852    auth: &StoredCodexAuth,
853) -> std::result::Result<Value, CodexRequestError> {
854    let mut last_error = CodexRequestError {
855        status: None,
856        message: "No attempts made".to_string(),
857    };
858    let request_bytes = serde_json::to_vec(body)
859        .map(|bytes| bytes.len())
860        .unwrap_or(0);
861
862    for attempt in 0..3 {
863        if attempt > 0 {
864            let delay_secs = 1u64 << (attempt - 1);
865            tracing::warn!(
866                attempt = attempt + 1,
867                backoff_secs = delay_secs,
868                endpoint = "responses",
869                "retrying codex HTTP request after backoff"
870            );
871            sleep(Duration::from_secs(delay_secs)).await;
872        }
873
874        let attempt_started = Instant::now();
875        tracing::debug!(
876            attempt = attempt + 1,
877            endpoint = "responses",
878            request_bytes,
879            "starting codex HTTP attempt"
880        );
881
882        let mut req = client
883            .post(url)
884            .header("Authorization", format!("Bearer {}", auth.access))
885            .header("originator", ORIGINATOR)
886            .header("User-Agent", codex_user_agent())
887            .header(header::ACCEPT, "text/event-stream")
888            .header(header::CONTENT_TYPE, "application/json");
889
890        if !auth.account_id.is_empty() {
891            req = req.header("ChatGPT-Account-Id", auth.account_id.as_str());
892        }
893        if auth.auth_type == AUTH_TYPE_API_KEY {
894            req = req.header("OpenAI-Beta", "responses=v1");
895        }
896
897        let response = req
898            .json(body)
899            .send()
900            .await
901            .map_err(|error| CodexRequestError {
902                status: None,
903                message: format!("HTTP request failed for {url}: {error}"),
904            })?;
905
906        let status = response.status();
907        let content_type = response
908            .headers()
909            .get(header::CONTENT_TYPE)
910            .and_then(|value| value.to_str().ok())
911            .map(str::to_string);
912        let response_body = response.text().await.map_err(|error| CodexRequestError {
913            status: Some(status),
914            message: format!("Failed to read response body from {url}: {error}"),
915        })?;
916        let response_bytes = response_body.len();
917
918        if status.is_success() {
919            tracing::info!(attempt = attempt + 1, status = status.as_u16(), endpoint = "responses", request_bytes, response_bytes, latency_ms = attempt_started.elapsed().as_millis() as u64, content_type = ?content_type, "codex HTTP attempt succeeded");
920            return parse_codex_success_body(url, status, content_type.as_deref(), &response_body);
921        }
922
923        let error = CodexRequestError {
924            status: Some(status),
925            message: format!(
926                "HTTP {} from {url}: {}",
927                status.as_u16(),
928                truncate(&response_body)
929            ),
930        };
931        if status == StatusCode::UNAUTHORIZED {
932            tracing::warn!(
933                attempt = attempt + 1,
934                status = status.as_u16(),
935                request_bytes,
936                response_bytes,
937                latency_ms = attempt_started.elapsed().as_millis() as u64,
938                endpoint = "responses",
939                retryable = false,
940                "codex HTTP attempt failed with unauthorized status"
941            );
942            return Err(error);
943        }
944        if status.as_u16() == 429 || status.is_server_error() {
945            tracing::warn!(
946                attempt = attempt + 1,
947                status = status.as_u16(),
948                request_bytes,
949                response_bytes,
950                latency_ms = attempt_started.elapsed().as_millis() as u64,
951                endpoint = "responses",
952                retryable = true,
953                "codex HTTP attempt failed with retryable status"
954            );
955            last_error = error;
956            continue;
957        }
958        tracing::error!(
959            attempt = attempt + 1,
960            status = status.as_u16(),
961            request_bytes,
962            response_bytes,
963            latency_ms = attempt_started.elapsed().as_millis() as u64,
964            endpoint = "responses",
965            retryable = false,
966            "codex HTTP attempt failed with non-retryable status"
967        );
968        return Err(error);
969    }
970
971    Err(last_error)
972}
973
974fn parse_codex_success_body(
975    url: &str,
976    status: StatusCode,
977    content_type: Option<&str>,
978    response_body: &str,
979) -> std::result::Result<Value, CodexRequestError> {
980    let looks_like_sse = content_type
981        .map(|value| value.contains("text/event-stream"))
982        .unwrap_or(false)
983        || response_body.lines().any(|line| line.starts_with("data:"));
984    tracing::debug!(status = status.as_u16(), response_bytes = response_body.len(), looks_like_sse, content_type = ?content_type, endpoint = "responses", "parsing codex success body");
985    if looks_like_sse {
986        return parse_codex_sse_response(response_body).map_err(|message| CodexRequestError {
987            status: Some(status),
988            message: format!(
989                "Failed to parse SSE response from {url}: {message}\nBody: {}",
990                truncate(response_body)
991            ),
992        });
993    }
994
995    serde_json::from_str::<Value>(response_body).map_err(|error| CodexRequestError {
996        status: Some(status),
997        message: format!(
998            "Failed to parse response from {url}: {error}\nBody: {}",
999            truncate(response_body)
1000        ),
1001    })
1002}
1003
1004fn parse_codex_sse_response(response_body: &str) -> std::result::Result<Value, String> {
1005    let mut final_response = None;
1006    let mut output_items: Vec<(usize, Value)> = Vec::new();
1007    let started = Instant::now();
1008    let payloads = sse_data_payloads(response_body);
1009    let payload_count = payloads.len();
1010    let mut terminal_event: Option<String> = None;
1011
1012    for data in payloads {
1013        if data == "[DONE]" {
1014            continue;
1015        }
1016
1017        let event: Value = serde_json::from_str(&data)
1018            .map_err(|error| format!("invalid SSE JSON event: {error}"))?;
1019        match event.get("type").and_then(Value::as_str) {
1020            Some("error") | Some("response.failed") => {
1021                return Err(codex_event_error_message(&event)
1022                    .unwrap_or_else(|| format!("Codex error event: {event}")));
1023            }
1024            Some("response.output_item.done") => {
1025                if let Some(item) = event.get("item").cloned() {
1026                    let output_index = event
1027                        .get("output_index")
1028                        .and_then(Value::as_u64)
1029                        .and_then(|index| usize::try_from(index).ok())
1030                        .unwrap_or(output_items.len());
1031                    output_items.retain(|(index, _)| *index != output_index);
1032                    output_items.push((output_index, item));
1033                }
1034            }
1035            Some("response.completed") | Some("response.done") | Some("response.incomplete") => {
1036                terminal_event = event
1037                    .get("type")
1038                    .and_then(Value::as_str)
1039                    .map(str::to_string);
1040                if let Some(response) = event.get("response").and_then(Value::as_object) {
1041                    if response.get("status").and_then(Value::as_str) == Some("failed") {
1042                        return Err(codex_event_error_message(&event)
1043                            .unwrap_or_else(|| format!("Codex response failed: {event}")));
1044                    }
1045                    let mut response_value = Value::Object(response.clone());
1046                    if response_output_is_empty(&response_value) && !output_items.is_empty() {
1047                        output_items.sort_by_key(|(index, _)| *index);
1048                        response_value["output"] = Value::Array(
1049                            output_items
1050                                .iter()
1051                                .map(|(_, item)| item.clone())
1052                                .collect::<Vec<_>>(),
1053                        );
1054                    }
1055                    final_response = Some(response_value);
1056                }
1057            }
1058            _ => {}
1059        }
1060    }
1061
1062    tracing::info!(
1063        payload_count,
1064        output_item_done_count = output_items.len(),
1065        terminal_event = ?terminal_event,
1066        response_bytes = response_body.len(),
1067        latency_ms = started.elapsed().as_millis() as u64,
1068        "parsed codex SSE response"
1069    );
1070
1071    final_response.ok_or_else(|| "SSE stream did not include a final response event".to_string())
1072}
1073
1074fn response_output_is_empty(response: &Value) -> bool {
1075    response
1076        .get("output")
1077        .and_then(Value::as_array)
1078        .map(Vec::is_empty)
1079        .unwrap_or(true)
1080}
1081
1082fn sse_data_payloads(response_body: &str) -> Vec<String> {
1083    let mut payloads = Vec::new();
1084    let mut current = String::new();
1085
1086    for line in response_body.lines() {
1087        if let Some(data) = line.strip_prefix("data:") {
1088            if !current.is_empty() {
1089                current.push('\n');
1090            }
1091            current.push_str(data.trim_start());
1092        } else if line.trim().is_empty() && !current.is_empty() {
1093            payloads.push(std::mem::take(&mut current));
1094        }
1095    }
1096
1097    if !current.is_empty() {
1098        payloads.push(current);
1099    }
1100
1101    payloads
1102}
1103
1104fn codex_event_error_message(event: &Value) -> Option<String> {
1105    event
1106        .get("response")
1107        .and_then(|response| response.get("error"))
1108        .and_then(|error| error.get("message"))
1109        .and_then(Value::as_str)
1110        .or_else(|| {
1111            event
1112                .get("error")
1113                .and_then(|error| error.get("message"))
1114                .and_then(Value::as_str)
1115        })
1116        .or_else(|| event.get("message").and_then(Value::as_str))
1117        .filter(|message| !message.is_empty())
1118        .map(str::to_string)
1119}
1120
1121fn codex_responses_url(base_url: &str) -> String {
1122    let raw = if base_url.trim().is_empty() {
1123        DEFAULT_CODEX_BASE_URL
1124    } else {
1125        base_url.trim()
1126    };
1127    let normalized = raw.trim_end_matches('/');
1128    if normalized.ends_with("/codex/responses") {
1129        normalized.to_string()
1130    } else if normalized.ends_with("/codex") {
1131        format!("{normalized}/responses")
1132    } else {
1133        format!("{normalized}/codex/responses")
1134    }
1135}
1136
1137fn auth_file_path() -> Result<PathBuf> {
1138    crate::paths::sac_home_dir()
1139        .map(|dir| dir.join("auth.json"))
1140        .ok_or_else(|| anyhow!("could not determine SAC_HOME or HOME for Codex auth storage"))
1141}
1142
1143fn auth_lock_path() -> Result<PathBuf> {
1144    Ok(auth_file_path()?.with_file_name("auth.json.lock"))
1145}
1146
1147fn acquire_auth_lock() -> Result<FileLock> {
1148    let path = auth_lock_path()?;
1149    if let Some(parent) = path.parent() {
1150        fs::create_dir_all(parent)
1151            .with_context(|| format!("failed to create {}", parent.display()))?;
1152    }
1153    FileLock::acquire(&path)
1154}
1155
1156fn with_auth_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
1157    let lock = acquire_auth_lock()?;
1158    let result = operation();
1159    drop(lock);
1160    result
1161}
1162
1163fn read_auth_file_optional() -> Result<Option<StoredCodexAuth>> {
1164    let path = auth_file_path()?;
1165    let raw = match fs::read_to_string(&path) {
1166        Ok(raw) => raw,
1167        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1168        Err(error) => {
1169            return Err(error).with_context(|| format!("failed to read {}", path.display()))
1170        }
1171    };
1172    let auth: StoredCodexAuth = serde_json::from_str(&raw)
1173        .with_context(|| format!("failed to parse {}", path.display()))?;
1174    let valid_types = [AUTH_TYPE, AUTH_TYPE_API_KEY, AUTH_TYPE_PAT];
1175    if !valid_types.contains(&auth.auth_type.as_str()) {
1176        return Err(anyhow!(
1177            "{} contains unsupported auth type '{}'",
1178            path.display(),
1179            auth.auth_type
1180        ));
1181    }
1182    Ok(Some(auth))
1183}
1184
1185fn read_auth_file() -> Result<StoredCodexAuth> {
1186    read_auth_file_optional()?.ok_or_else(|| {
1187        anyhow!("Codex auth is not configured. Run `sac codex-auth` to sign in with ChatGPT.")
1188    })
1189}
1190
1191fn write_auth_file(auth: &StoredCodexAuth) -> Result<()> {
1192    let path = auth_file_path()?;
1193    if let Some(parent) = path.parent() {
1194        fs::create_dir_all(parent)
1195            .with_context(|| format!("failed to create {}", parent.display()))?;
1196    }
1197    let raw = serde_json::to_string_pretty(auth).context("failed to serialize Codex auth")?;
1198    let mut options = OpenOptions::new();
1199    options.create(true).truncate(true).write(true);
1200    #[cfg(unix)]
1201    {
1202        use std::os::unix::fs::OpenOptionsExt;
1203        options.mode(0o600);
1204    }
1205    let mut file = options
1206        .open(&path)
1207        .with_context(|| format!("failed to open {}", path.display()))?;
1208    use std::io::Write;
1209    file.write_all(raw.as_bytes())
1210        .with_context(|| format!("failed to write {}", path.display()))?;
1211    file.flush()
1212        .with_context(|| format!("failed to flush {}", path.display()))?;
1213    #[cfg(unix)]
1214    {
1215        use std::os::unix::fs::PermissionsExt;
1216        fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
1217            .with_context(|| format!("failed to chmod {}", path.display()))?;
1218    }
1219    Ok(())
1220}
1221
1222struct FileLock {
1223    file: File,
1224}
1225
1226impl FileLock {
1227    fn acquire(path: &Path) -> Result<Self> {
1228        let file = OpenOptions::new()
1229            .create(true)
1230            .read(true)
1231            .write(true)
1232            .open(path)
1233            .with_context(|| format!("failed to open auth lock {}", path.display()))?;
1234        lock_file(&file).with_context(|| format!("failed to lock {}", path.display()))?;
1235        Ok(Self { file })
1236    }
1237}
1238
1239impl Drop for FileLock {
1240    fn drop(&mut self) {
1241        let _ = unlock_file(&self.file);
1242    }
1243}
1244
1245#[cfg(unix)]
1246fn lock_file(file: &File) -> io::Result<()> {
1247    use std::os::unix::io::AsRawFd;
1248    let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
1249    if result == 0 {
1250        Ok(())
1251    } else {
1252        Err(io::Error::last_os_error())
1253    }
1254}
1255
1256#[cfg(unix)]
1257fn unlock_file(file: &File) -> io::Result<()> {
1258    use std::os::unix::io::AsRawFd;
1259    let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
1260    if result == 0 {
1261        Ok(())
1262    } else {
1263        Err(io::Error::last_os_error())
1264    }
1265}
1266
1267#[cfg(not(unix))]
1268fn lock_file(_file: &File) -> io::Result<()> {
1269    Ok(())
1270}
1271
1272#[cfg(not(unix))]
1273fn unlock_file(_file: &File) -> io::Result<()> {
1274    Ok(())
1275}
1276
1277fn extract_account_id(token: &str) -> Option<String> {
1278    let payload = decode_jwt_payload(token)?;
1279    payload
1280        .get("https://api.openai.com/auth")
1281        .and_then(|auth| auth.get("chatgpt_account_id"))
1282        .and_then(Value::as_str)
1283        .or_else(|| payload.get("chatgpt_account_id").and_then(Value::as_str))
1284        .or_else(|| {
1285            payload
1286                .get("organizations")
1287                .and_then(Value::as_array)
1288                .and_then(|orgs| orgs.first())
1289                .and_then(|org| org.get("id"))
1290                .and_then(Value::as_str)
1291        })
1292        .filter(|id| !id.is_empty())
1293        .map(str::to_string)
1294}
1295
1296fn decode_jwt_payload(token: &str) -> Option<Value> {
1297    let mut parts = token.split('.');
1298    let _header = parts.next()?;
1299    let payload = parts.next()?;
1300    let _signature = parts.next()?;
1301    let bytes = base64_url_decode(payload)?;
1302    serde_json::from_slice(&bytes).ok()
1303}
1304
1305fn base64_url_decode(input: &str) -> Option<Vec<u8>> {
1306    let mut bits = 0u32;
1307    let mut bit_count = 0u8;
1308    let mut out = Vec::with_capacity(input.len() * 3 / 4);
1309
1310    for byte in input.bytes() {
1311        if byte == b'=' {
1312            break;
1313        }
1314        let value = match byte {
1315            b'A'..=b'Z' => byte - b'A',
1316            b'a'..=b'z' => byte - b'a' + 26,
1317            b'0'..=b'9' => byte - b'0' + 52,
1318            b'-' | b'+' => 62,
1319            b'_' | b'/' => 63,
1320            _ => return None,
1321        } as u32;
1322        bits = (bits << 6) | value;
1323        bit_count += 6;
1324        while bit_count >= 8 {
1325            bit_count -= 8;
1326            out.push(((bits >> bit_count) & 0xff) as u8);
1327        }
1328    }
1329
1330    Some(out)
1331}
1332
1333fn interval_secs(value: Option<&Value>) -> Option<u64> {
1334    match value {
1335        Some(Value::Number(number)) => number.as_u64(),
1336        Some(Value::String(text)) => text.parse().ok(),
1337        _ => None,
1338    }
1339}
1340
1341fn now_ms() -> u64 {
1342    SystemTime::now()
1343        .duration_since(UNIX_EPOCH)
1344        .unwrap_or_default()
1345        .as_millis()
1346        .try_into()
1347        .unwrap_or(u64::MAX)
1348}
1349
1350fn expiry_status(expires_at_ms: u64) -> String {
1351    let now = now_ms();
1352    if expires_at_ms <= now {
1353        let seconds = now.saturating_sub(expires_at_ms) / 1000;
1354        format!("expired {seconds}s ago")
1355    } else {
1356        let seconds = expires_at_ms.saturating_sub(now) / 1000;
1357        format!("in {}s", seconds)
1358    }
1359}
1360
1361fn codex_user_agent() -> String {
1362    format!("sac/{}", env!("CARGO_PKG_VERSION"))
1363}
1364
1365fn truncate(value: &str) -> String {
1366    value.chars().take(500).collect()
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371    use super::*;
1372
1373    #[test]
1374    fn resolves_codex_responses_urls() {
1375        assert_eq!(
1376            codex_responses_url("https://chatgpt.com/backend-api"),
1377            "https://chatgpt.com/backend-api/codex/responses"
1378        );
1379        assert_eq!(
1380            codex_responses_url("https://chatgpt.com/backend-api/codex"),
1381            "https://chatgpt.com/backend-api/codex/responses"
1382        );
1383        assert_eq!(
1384            codex_responses_url("https://chatgpt.com/backend-api/codex/responses"),
1385            "https://chatgpt.com/backend-api/codex/responses"
1386        );
1387    }
1388
1389    #[test]
1390    fn extracts_account_id_from_nested_jwt_claim() {
1391        let token = concat!(
1392            "e30.",
1393            "eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOns",
1394            "iY2hhdGdwdF9hY2NvdW50X2lkIjoid29ya3NwYWNlLTEyMyJ9fQ.",
1395            "sig"
1396        );
1397
1398        assert_eq!(extract_account_id(token).as_deref(), Some("workspace-123"));
1399    }
1400
1401    #[test]
1402    fn builds_codex_responses_stream_request() {
1403        let request = codex_responses_request(
1404            "gpt-5.5",
1405            Some(&ReasoningEffort::High),
1406            None,
1407            None,
1408            &[
1409                Message::System {
1410                    content: "system instructions".to_string(),
1411                },
1412                Message::User {
1413                    content: "hello".to_string(),
1414                },
1415            ],
1416            &[],
1417        );
1418
1419        assert_eq!(request["model"], "gpt-5.5");
1420        assert_eq!(request["instructions"], "system instructions");
1421        assert_eq!(request["store"], false);
1422        assert_eq!(request["stream"], true);
1423        assert_eq!(request["text"]["verbosity"], "low");
1424        assert_eq!(request["tool_choice"], "auto");
1425        assert_eq!(request["parallel_tool_calls"], true);
1426        assert_eq!(request["reasoning"]["effort"], "high");
1427        assert_eq!(request["include"][0], "reasoning.encrypted_content");
1428        assert_eq!(request["input"].as_array().unwrap().len(), 1);
1429        assert_eq!(request["input"][0]["role"], "user");
1430    }
1431
1432    #[test]
1433    fn parses_codex_sse_final_response() {
1434        let body = concat!(
1435            "event: response.output_item.done\n",
1436            "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello\"}]}}\n\n",
1437            "event: response.completed\n",
1438            "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
1439            "data: [DONE]\n\n"
1440        );
1441
1442        let parsed = parse_codex_sse_response(body).unwrap();
1443        assert_eq!(parsed["status"], "completed");
1444        assert_eq!(parsed["output"][0]["type"], "message");
1445        assert_eq!(parsed["output"][0]["content"][0]["text"], "hello");
1446        assert_eq!(parsed["usage"]["total_tokens"], 3);
1447    }
1448}