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, ¤t.refresh).await?;
744 let refreshed = auth_from_token_response(tokens, Some(¤t.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}