Files
optimclaw/src/llm/openai_codex_session.rs
T
3da9810e87 feat(llm): Add OpenAI Codex (ChatGPT subscription) as LLM provider (#1461)
* feat(llm): add OpenAI Codex backend config and OAuth session manager

Add OpenAiCodex as a new LLM backend variant with config for auth
endpoint, API base URL, client ID, and session persistence path.

The session manager implements OpenAI's device code auth flow
(headless-friendly, no browser required on the server) with automatic
token refresh, following the same persistence pattern as the existing
NEAR AI session manager.

Closes #742

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(llm): add Responses API client and token-refreshing decorator

Native Responses API client for chatgpt.com/backend-api/codex/responses,
the endpoint that works with ChatGPT subscription tokens. Handles SSE
streaming, text completions, and tool call round-trips.

Token-refreshing decorator wraps the provider to pre-emptively refresh
OAuth tokens before API calls and retry once on auth failures. Reports
zero cost since billing is through subscription.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(llm): wire OpenAI Codex into provider factory, CLI, and setup wizard

Connect the new provider to the LLM factory, add openai_codex to the
CLI --backend flag, and add it as an option in the onboarding wizard.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(llm): address PR #744 review feedback (20 items)

Review fixes for the OpenAI Codex provider PR:

- Remove dead `generate_pkce()` code (device flow gets PKCE from server)
- Fix `refresh_tokens()` to use `.form()` instead of `.json()` per OAuth spec
- Inline codex dispatch into `build_provider_chain()` (single async function,
  no separate `assemble_provider_chain()` helper — matches main's pattern)
- Remove Clone from `OpenAiCodexSession`, restrict fields to `pub(crate)`
- Propagate HTTP client builder error instead of silent fallback
- Redact device code response body from debug log
- Change `set_model()` in TokenRefreshingProvider to delegate to inner
- Replace hardcoded `/tmp/` test path with `tempfile::tempdir()`
- Accept `request_timeout_secs` from config instead of hardcoded 300s
- Parse `Retry-After` header on 429 responses (matches nearai_chat.rs pattern)
- Reuse `normalize_schema_strict()` for Codex tool definitions
- Add warning log for dropped image attachments
- Add doc comments on `list_models()` and `include` field
- Add `OPENAI_CODEX_API_URL` to `.env.example`
- Fix codex error message in `create_llm_provider()` for clarity
- Revert unrelated `.worktrees` addition to `.gitignore`
- Update `src/llm/CLAUDE.md` with Codex provider docs

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review feedback and harden OpenAI Codex provider (takeover #744)

Security:
- Add SSRF validation (validate_base_url) on OPENAI_CODEX_AUTH_URL and
  OPENAI_CODEX_API_URL, matching the pattern used by all other base URL
  configs (regression test for #1103 included)

Correctness:
- Add missing cache_write_multiplier() and cache_read_discount() trait
  delegation in TokenRefreshingProvider
- Cap device-code polling backoff at 60s to prevent unbounded interval
  growth on repeated 429 responses
- Default expires_in to 3600s when server returns 0, preventing
  immediately-expired sessions
- Fix pre-existing SseEvent::JobResult missing fallback_deliverable field
  in job_monitor.rs tests

Cleanup:
- Extract duplicated make_test_jwt() and test_codex_config() into shared
  codex_test_helpers module

Co-Authored-By: Sanjeev-S <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review feedback on OpenAI Codex provider (#1461)

- Login command now resolves OPENAI_CODEX_* env overrides even when
  LLM_BACKEND isn't set to openai_codex (Copilot review)
- Setup wizard "Keep current provider?" for codex no longer re-triggers
  device code login — mirrors Bedrock's keep-and-return pattern (Copilot)
- Revert provider init log from info back to debug (Copilot)
- Add warning log when token expires_in=0, before defaulting to 3600s
  (Gemini review)

Co-Authored-By: Sanjeev-S <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Sanjeev Suresh <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 08:14:20 -07:00

732 lines
26 KiB
Rust

//! OAuth 2.0 session manager for OpenAI Codex (ChatGPT subscription).
//!
//! Supports two auth flows:
//! - **Device Code** (primary): Works on headless servers, no browser needed.
//! - **Browser PKCE** (fallback): Standard OAuth for local machines.
//!
//! Tokens are persisted to `~/.ironclaw/openai_codex_session.json` and
//! auto-refreshed before expiry.
use chrono::{DateTime, Utc};
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock};
use crate::config::OpenAiCodexConfig;
use crate::error::LlmError;
/// Persisted OAuth session data.
///
/// Note: `Debug` is manually implemented to redact tokens.
#[derive(Serialize, Deserialize)]
pub struct OpenAiCodexSession {
pub(crate) access_token: String,
pub(crate) refresh_token: String,
pub(crate) expires_at: DateTime<Utc>,
pub(crate) created_at: DateTime<Utc>,
}
impl std::fmt::Debug for OpenAiCodexSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAiCodexSession")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &"[REDACTED]")
.field("expires_at", &self.expires_at)
.field("created_at", &self.created_at)
.finish()
}
}
/// Request body for the device code usercode endpoint.
#[derive(Debug, Serialize)]
struct UserCodeRequest {
client_id: String,
}
/// Response from the device code usercode endpoint.
#[derive(Debug, Deserialize)]
struct UserCodeResponse {
/// Unique ID for this device auth session.
device_auth_id: String,
/// Code the user enters in their browser.
user_code: String,
/// URL where the user enters the code (may not be present).
#[serde(default = "default_verification_uri")]
verification_uri: String,
/// Polling interval in seconds (OpenAI sends this as a string).
#[serde(
default = "default_interval",
deserialize_with = "deserialize_string_or_u64"
)]
interval: u64,
/// Expiry timestamp (OpenAI sends `expires_at` as ISO-8601).
#[serde(default)]
expires_at: Option<String>,
/// Seconds until the device code expires (standard field, may not be present).
#[serde(default)]
expires_in: Option<u64>,
}
fn default_verification_uri() -> String {
"https://auth.openai.com/codex/device".to_string()
}
fn default_interval() -> u64 {
5
}
/// Deserialize a value that may be either a string or a number as u64.
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct StringOrU64;
impl<'de> de::Visitor<'de> for StringOrU64 {
type Value = u64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or integer")
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
Ok(v)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
v.parse().map_err(de::Error::custom)
}
}
deserializer.deserialize_any(StringOrU64)
}
impl UserCodeResponse {
/// Get the expiry duration in seconds, from either `expires_in` or `expires_at`.
fn expires_in_secs(&self) -> u64 {
if let Some(secs) = self.expires_in {
return secs;
}
if let Some(ref ts) = self.expires_at
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
{
let remaining = dt.signed_duration_since(Utc::now()).num_seconds();
return remaining.max(0) as u64;
}
900 // default 15 minutes
}
}
/// Request body for polling the device auth token endpoint.
#[derive(Debug, Serialize)]
struct DeviceTokenPollRequest {
device_auth_id: String,
user_code: String,
}
/// Successful response from the device auth token endpoint.
/// Returns an authorization code + PKCE pair for the final token exchange.
#[derive(Debug, Deserialize)]
struct DeviceAuthCodeResponse {
authorization_code: String,
#[allow(dead_code)]
code_challenge: String,
code_verifier: String,
}
/// Response from the final OAuth token exchange.
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: String,
#[serde(default)]
expires_in: u64,
#[serde(default)]
#[allow(dead_code)]
token_type: String,
}
/// Manages OpenAI Codex OAuth sessions with persistence and auto-refresh.
pub struct OpenAiCodexSessionManager {
config: OpenAiCodexConfig,
client: Client,
session: RwLock<Option<OpenAiCodexSession>>,
renewal_lock: Mutex<()>,
}
impl OpenAiCodexSessionManager {
/// Create a new session manager. Tries to load existing session from disk.
///
/// # Errors
///
/// Returns `LlmError` if the HTTP client cannot be constructed.
pub fn new(config: OpenAiCodexConfig) -> Result<Self, LlmError> {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static(concat!("ironclaw/", env!("CARGO_PKG_VERSION"))),
);
let client = Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "openai_codex".into(),
reason: format!("HTTP client build failed: {e}"),
})?;
let mgr = Self {
config,
client,
session: RwLock::new(None),
renewal_lock: Mutex::new(()),
};
// Try synchronous load from disk during construction
if let Ok(data) = std::fs::read_to_string(&mgr.config.session_path)
&& let Ok(session) = serde_json::from_str::<OpenAiCodexSession>(&data)
&& let Ok(mut guard) = mgr.session.try_write()
{
*guard = Some(session);
tracing::info!(
"Loaded OpenAI Codex session from {}",
mgr.config.session_path.display()
);
}
Ok(mgr)
}
/// Check if we have a session (may be expired).
pub async fn has_session(&self) -> bool {
self.session.read().await.is_some()
}
/// Check if the current access token needs refreshing.
pub async fn needs_refresh(&self) -> bool {
let guard = self.session.read().await;
match guard.as_ref() {
None => true,
Some(s) => {
let margin =
chrono::Duration::seconds(self.config.token_refresh_margin_secs as i64);
Utc::now() + margin >= s.expires_at
}
}
}
/// Get the current access token, refreshing if needed.
///
/// If the token is within the refresh margin, silently refreshes first.
/// If no session exists, returns an AuthFailed error.
pub async fn get_access_token(&self) -> Result<SecretString, LlmError> {
if self.needs_refresh().await {
let has_refresh = self
.session
.read()
.await
.as_ref()
.map(|s| !s.refresh_token.is_empty())
.unwrap_or(false);
if has_refresh {
self.refresh_tokens().await?;
} else {
return Err(LlmError::AuthFailed {
provider: "openai_codex".to_string(),
});
}
}
let guard = self.session.read().await;
guard
.as_ref()
.map(|s| SecretString::from(s.access_token.clone()))
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})
}
/// Ensure we have a valid session. Loads from disk, refreshes, or prompts login.
pub async fn ensure_authenticated(&self) -> Result<(), LlmError> {
// Try loading from disk if we don't have a session
if !self.has_session().await {
let _ = self.load_session().await;
}
if !self.has_session().await {
// No session at all -- need to authenticate
return self.device_code_login().await;
}
if self.needs_refresh().await {
// Try refresh; if it fails, re-authenticate
match self.refresh_tokens().await {
Ok(()) => Ok(()),
Err(e) => {
tracing::info!("Token refresh failed ({}), re-authenticating...", e);
self.device_code_login().await
}
}
} else {
Ok(())
}
}
/// Run OpenAI's device code auth flow.
///
/// Uses OpenAI's custom `/api/accounts/deviceauth/*` endpoints (not the standard
/// Auth0 `/oauth/device/code` which is behind Cloudflare managed challenge).
///
/// Flow:
/// 1. POST `/api/accounts/deviceauth/usercode` → get device_auth_id + user_code
/// 2. Poll POST `/api/accounts/deviceauth/token` → get authorization_code + PKCE
/// 3. Exchange via POST `/oauth/token` → get access_token + refresh_token
pub async fn device_code_login(&self) -> Result<(), LlmError> {
let _guard = self.renewal_lock.lock().await;
let auth_base = format!("{}/api/accounts", self.config.auth_endpoint);
// Step 1: Request device code
let usercode_url = format!("{}/deviceauth/usercode", auth_base);
let resp = self
.client
.post(&usercode_url)
.json(&UserCodeRequest {
client_id: self.config.client_id.clone(),
})
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device code request failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device code request failed: HTTP {} -- {}", status, body),
});
}
let body_text = resp
.text()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to read device code response: {}", e),
})?;
tracing::debug!("Device code response received ({} bytes)", body_text.len());
let device: UserCodeResponse =
serde_json::from_str(&body_text).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!(
"Failed to parse device code response: {} ({} bytes)",
e,
body_text.len()
),
})?;
// Step 2: Display code to user
println!();
println!("===========================================================");
println!(" OpenAI Codex Authentication ");
println!("===========================================================");
println!();
println!(" 1. Open this URL in any browser:");
println!(" {}", device.verification_uri);
println!();
println!(" 2. Enter this code:");
println!();
println!(" [ {} ]", device.user_code);
println!();
let expires_secs = device.expires_in_secs();
println!(
" Waiting for authorization... (expires in {} min)",
expires_secs / 60
);
println!("===========================================================");
println!();
// Step 3: Poll for authorization code
let poll_url = format!("{}/deviceauth/token", auth_base);
let mut interval = std::time::Duration::from_secs(device.interval.max(5));
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(expires_secs);
let auth_code = loop {
tokio::time::sleep(interval).await;
if tokio::time::Instant::now() >= deadline {
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: "Device code authorization timed out".to_string(),
});
}
let resp = self
.client
.post(&poll_url)
.json(&DeviceTokenPollRequest {
device_auth_id: device.device_auth_id.clone(),
user_code: device.user_code.clone(),
})
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token poll request failed: {}", e),
})?;
let status = resp.status();
if status.is_success() {
let code_resp: DeviceAuthCodeResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse auth code response: {}", e),
})?;
break code_resp;
}
// 403 = authorization_pending, keep polling
// 404 = device code not found / not enabled
if status == reqwest::StatusCode::FORBIDDEN {
continue;
}
if status == reqwest::StatusCode::NOT_FOUND {
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: "Device code login is not enabled. Please check your OpenAI account settings.".to_string(),
});
}
// Slow down on 429, cap at 60s to avoid unbounded growth
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
interval = (interval + std::time::Duration::from_secs(5))
.min(std::time::Duration::from_secs(60));
continue;
}
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device auth poll failed: HTTP {} -- {}", status, body),
});
};
// Step 4: Exchange authorization code for tokens (form-encoded, per Auth0 spec)
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
let resp = self
.client
.post(&token_url)
.form(&[
("grant_type", "authorization_code"),
("code", &auth_code.authorization_code),
("code_verifier", &auth_code.code_verifier),
("client_id", &self.config.client_id),
(
"redirect_uri",
&format!("{}/deviceauth/callback", self.config.auth_endpoint),
),
])
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token exchange failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token exchange failed: HTTP {} -- {}", status, body),
});
}
let token_resp: TokenResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse token response: {}", e),
})?;
let session = OpenAiCodexSession {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: Utc::now()
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
token_resp.expires_in
} else {
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
3600
} as i64),
created_at: Utc::now(),
};
self.save_session(&session).await?;
self.set_session(session).await;
println!();
println!("Authentication successful!");
println!();
Ok(())
}
/// Refresh the access token using the refresh token.
pub async fn refresh_tokens(&self) -> Result<(), LlmError> {
let _guard = self.renewal_lock.lock().await;
// Double-check: another task may have refreshed while we waited on the lock
if !self.needs_refresh().await {
return Ok(());
}
let refresh_token = {
let guard = self.session.read().await;
guard
.as_ref()
.map(|s| s.refresh_token.clone())
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})?
};
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
let resp = self
.client
.post(&token_url)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token.as_str()),
("client_id", self.config.client_id.as_str()),
])
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token refresh request failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token refresh failed: HTTP {} -- {}", status, body),
});
}
let token_resp: TokenResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse refresh response: {}", e),
})?;
let session = OpenAiCodexSession {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: Utc::now()
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
token_resp.expires_in
} else {
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
3600
} as i64),
created_at: Utc::now(),
};
self.save_session(&session).await?;
self.set_session(session).await;
tracing::debug!("OpenAI Codex token refreshed successfully");
Ok(())
}
/// Save session data to disk with restrictive permissions.
pub async fn save_session(&self, session: &OpenAiCodexSession) -> Result<(), LlmError> {
if let Some(parent) = self.config.session_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to create session directory: {}", e),
))
})?;
}
let json =
serde_json::to_string_pretty(session).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to serialize session: {}", e),
})?;
tokio::fs::write(&self.config.session_path, &json)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to write session file: {}", e),
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&self.config.session_path, perms)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to set permissions: {}", e),
))
})?;
}
Ok(())
}
/// Load session from disk.
pub async fn load_session(&self) -> Result<(), LlmError> {
let data = tokio::fs::read_to_string(&self.config.session_path)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to read session file: {}", e),
))
})?;
let session: OpenAiCodexSession =
serde_json::from_str(&data).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse session file: {}", e),
})?;
let mut guard = self.session.write().await;
*guard = Some(session);
tracing::info!(
"Loaded OpenAI Codex session from {}",
self.config.session_path.display()
);
Ok(())
}
/// Set session directly (for testing or after auth).
pub async fn set_session(&self, session: OpenAiCodexSession) {
let mut guard = self.session.write().await;
*guard = Some(session);
}
/// Handle a 401 response by refreshing, or re-authenticating.
pub async fn handle_auth_failure(&self) -> Result<(), LlmError> {
match self.refresh_tokens().await {
Ok(()) => Ok(()),
Err(_) => self.device_code_login().await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::codex_test_helpers::test_codex_config as test_config;
use tempfile::tempdir;
#[tokio::test]
async fn test_save_and_load_session() {
let dir = tempdir().unwrap();
let path = dir.path().join("session.json");
let config = test_config(path.clone());
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// No session initially
assert!(!mgr.has_session().await);
// Save a session
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
created_at: chrono::Utc::now(),
};
mgr.save_session(&session).await.unwrap();
mgr.set_session(session).await;
assert!(mgr.has_session().await);
// Load from disk in a new manager
let config2 = test_config(path);
let mgr2 = OpenAiCodexSessionManager::new(config2).unwrap();
mgr2.load_session().await.unwrap();
assert!(mgr2.has_session().await);
}
#[tokio::test]
async fn test_needs_refresh_when_near_expiry() {
let dir = tempdir().unwrap();
let config = test_config(dir.path().join("session.json"));
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// Token expiring in 2 minutes (margin is 300s = 5 min)
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(2),
created_at: chrono::Utc::now(),
};
mgr.set_session(session).await;
assert!(mgr.needs_refresh().await);
}
#[test]
fn device_code_parse_error_redacts_body() {
// Regression: the parse error used to include raw body_text which could
// contain sensitive auth data. Now it only shows byte count.
let body_text = r#"{"secret_token":"sk-12345","error":"unexpected"}"#;
let err: Result<UserCodeResponse, _> = serde_json::from_str(body_text);
assert!(err.is_err());
let e = err.unwrap_err();
let error_msg = format!(
"Failed to parse device code response: {} ({} bytes)",
e,
body_text.len()
);
assert!(
!error_msg.contains("sk-12345"),
"error message must not contain raw body: {error_msg}"
);
assert!(
error_msg.contains("bytes"),
"error message should show byte count"
);
}
#[tokio::test]
async fn test_no_refresh_when_fresh() {
let dir = tempdir().unwrap();
let config = test_config(dir.path().join("session.json"));
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// Token expiring in 30 minutes (margin is 300s = 5 min)
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
created_at: chrono::Utc::now(),
};
mgr.set_session(session).await;
assert!(!mgr.needs_refresh().await);
}
}