From bcae0852df5e041d8b8333d1d6bb604ffca70c3a Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Fri, 27 Feb 2026 07:46:27 +0400 Subject: [PATCH] feat(llm): add OpenAI Codex provider (Responses API + OAuth) Add LlmBackend::OpenAiCodex with dual auth support: - API key mode (api.openai.com, pay-per-token) - OAuth mode (chatgpt.com via Codex CLI tokens, subscription billing) Implements the Responses API wire format (/v1/responses) with flat input/output arrays, instructions field, and flat tool definitions. Includes token manager with 401 retry and disk reload, setup wizard integration, config resolution, and 30 unit tests. Co-Authored-By: Claude Opus 4.6 --- .env.example | 14 +- src/config/llm.rs | 264 +++++++++- src/config/mod.rs | 5 +- src/llm/mod.rs | 26 + src/llm/openai_codex.rs | 1007 +++++++++++++++++++++++++++++++++++++++ src/setup/wizard.rs | 109 ++++- 6 files changed, 1420 insertions(+), 5 deletions(-) create mode 100644 src/llm/openai_codex.rs diff --git a/.env.example b/.env.example index 64a688a8..be750e9e 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10 # LLM Provider # LLM_BACKEND=nearai # default -# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil +# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil, openai_codex # === NEAR AI (Chat Completions API) === # Two auth modes: @@ -57,6 +57,18 @@ NEARAI_AUTH_URL=https://private.near.ai # LLM_BASE_URL=https://api.fireworks.ai/inference/v1 # LLM_API_KEY=fw_... +# === OpenAI Codex (Responses API) === +# Two auth modes: +# 1. API key: Standard OpenAI billing (api.openai.com/v1/responses) +# 2. Codex CLI OAuth: ChatGPT subscription billing (chatgpt.com) +# Reads token from ~/.codex/auth.json (or $CODEX_HOME/auth.json) +# OPENAI_CODEX_MODEL=gpt-5.3-codex +# LLM_BACKEND=openai_codex +# OPENAI_CODEX_API_KEY=sk-... # API key mode +# CODEX_AUTH_PATH=~/.codex/auth.json # OAuth mode (default path) +# OPENAI_CODEX_ACCOUNT_ID=... # Required for ChatGPT endpoint +# OPENAI_CODEX_BASE_URL=... # Override base URL + # For full provider setup guide see docs/LLM_PROVIDERS.md # Channel Configuration diff --git a/src/config/llm.rs b/src/config/llm.rs index 60ff9d7f..83b33096 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -25,6 +25,8 @@ pub enum LlmBackend { OpenAiCompatible, /// Tinfoil private inference Tinfoil, + /// OpenAI Codex via Responses API (ChatGPT OAuth or API key) + OpenAiCodex, } impl std::str::FromStr for LlmBackend { @@ -38,8 +40,9 @@ impl std::str::FromStr for LlmBackend { "ollama" => Ok(Self::Ollama), "openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible), "tinfoil" => Ok(Self::Tinfoil), + "openai_codex" | "codex" => Ok(Self::OpenAiCodex), _ => Err(format!( - "invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil", + "invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil, openai_codex", s )), } @@ -55,6 +58,7 @@ impl std::fmt::Display for LlmBackend { Self::Ollama => write!(f, "ollama"), Self::OpenAiCompatible => write!(f, "openai_compatible"), Self::Tinfoil => write!(f, "tinfoil"), + Self::OpenAiCodex => write!(f, "openai_codex"), } } } @@ -102,6 +106,29 @@ pub struct TinfoilConfig { pub model: String, } +/// Configuration for OpenAI Codex via Responses API. +/// +/// Supports two auth modes: +/// - **API key**: Standard OpenAI billing via `api.openai.com/v1/responses` +/// - **OAuth**: ChatGPT subscription billing via `chatgpt.com/backend-api/codex/responses`, +/// using tokens from the Codex CLI (`~/.codex/auth.json`) +#[derive(Debug, Clone)] +pub struct OpenAiCodexConfig { + /// Model name (default: "gpt-5.3-codex"). + pub model: String, + /// Base URL. Defaults based on auth mode: + /// - API key: `https://api.openai.com/v1` + /// - OAuth: `https://chatgpt.com/backend-api/codex` + pub base_url: String, + /// API key for api.openai.com (standard billing). + pub api_key: Option, + /// Path to Codex CLI auth.json for OAuth tokens. + /// Default: `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). + pub auth_path: PathBuf, + /// OpenAI account ID (required for ChatGPT endpoint). + pub account_id: Option, +} + /// LLM provider configuration. /// /// NEAR AI remains the default backend. Users can switch to other providers @@ -122,6 +149,8 @@ pub struct LlmConfig { pub openai_compatible: Option, /// Tinfoil config (populated when backend=tinfoil) pub tinfoil: Option, + /// OpenAI Codex config (populated when backend=openai_codex) + pub openai_codex: Option, } /// NEAR AI configuration. @@ -325,6 +354,32 @@ impl LlmConfig { None }; + let openai_codex = if backend == LlmBackend::OpenAiCodex { + let api_key = optional_env("OPENAI_CODEX_API_KEY")?.map(SecretString::from); + let model = + optional_env("OPENAI_CODEX_MODEL")?.unwrap_or_else(|| "gpt-5.3-codex".to_string()); + let auth_path = optional_env("CODEX_AUTH_PATH")? + .map(PathBuf::from) + .unwrap_or_else(default_codex_auth_path); + let account_id = optional_env("OPENAI_CODEX_ACCOUNT_ID")?; + let base_url = optional_env("OPENAI_CODEX_BASE_URL")?.unwrap_or_else(|| { + if api_key.is_some() { + "https://api.openai.com/v1".to_string() + } else { + "https://chatgpt.com/backend-api/codex".to_string() + } + }); + Some(OpenAiCodexConfig { + model, + base_url, + api_key, + auth_path, + account_id, + }) + } else { + None + }; + Ok(Self { backend, nearai, @@ -333,6 +388,7 @@ impl LlmConfig { ollama, openai_compatible, tinfoil, + openai_codex, }) } } @@ -371,6 +427,49 @@ fn parse_extra_headers(val: &str) -> Result, ConfigError> Ok(headers) } +/// Get the default Codex CLI auth.json path. +/// +/// Respects `$CODEX_HOME` if set, otherwise defaults to `~/.codex/auth.json`. +fn default_codex_auth_path() -> PathBuf { + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + return PathBuf::from(codex_home).join("auth.json"); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".codex") + .join("auth.json") +} + +/// Extract an OAuth access token from a Codex CLI `auth.json` file. +/// +/// Tries fields in order: `tokens.access_token`, `token`, `api_key`, `access_token`. +/// Returns `None` on any failure (file not found, parse error, no matching field). +pub fn extract_codex_oauth_token(auth_path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(auth_path).ok()?; + let json: serde_json::Value = serde_json::from_str(&content).ok()?; + + // Try nested tokens.access_token first (Codex CLI format) + if let Some(token) = json + .get("tokens") + .and_then(|t| t.get("access_token")) + .and_then(|v| v.as_str()) + && !token.is_empty() + { + return Some(token.to_string()); + } + + // Try top-level fields + for field in &["token", "api_key", "access_token"] { + if let Some(val) = json.get(field).and_then(|v| v.as_str()) + && !val.is_empty() + { + return Some(val.to_string()); + } + } + + None +} + /// Get the default session file path (~/.ironclaw/session.json). fn default_session_path() -> PathBuf { dirs::home_dir() @@ -508,4 +607,167 @@ mod tests { ] ); } + + /// Clear codex-related env vars for testing. + fn clear_codex_env() { + // SAFETY: Only called under ENV_MUTEX in tests. + unsafe { + std::env::remove_var("LLM_BACKEND"); + std::env::remove_var("OPENAI_CODEX_API_KEY"); + std::env::remove_var("OPENAI_CODEX_MODEL"); + std::env::remove_var("OPENAI_CODEX_BASE_URL"); + std::env::remove_var("OPENAI_CODEX_ACCOUNT_ID"); + std::env::remove_var("CODEX_AUTH_PATH"); + } + } + + #[test] + fn codex_defaults_model_and_oauth_base_url() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_codex_env(); + + let settings = Settings { + llm_backend: Some("openai_codex".to_string()), + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let codex = cfg.openai_codex.expect("codex config should be present"); + + assert_eq!(codex.model, "gpt-5.3-codex"); + // No API key → OAuth mode → ChatGPT base URL + assert!(codex.api_key.is_none()); + assert_eq!(codex.base_url, "https://chatgpt.com/backend-api/codex"); + assert!(codex.auth_path.to_string_lossy().contains("auth.json")); + } + + #[test] + fn codex_api_key_sets_openai_base_url() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_codex_env(); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::set_var("OPENAI_CODEX_API_KEY", "sk-test-key"); + } + + let settings = Settings { + llm_backend: Some("openai_codex".to_string()), + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let codex = cfg.openai_codex.expect("codex config should be present"); + + assert!(codex.api_key.is_some()); + assert_eq!(codex.base_url, "https://api.openai.com/v1"); + + // Cleanup + unsafe { + std::env::remove_var("OPENAI_CODEX_API_KEY"); + } + } + + #[test] + fn codex_env_vars_override_defaults() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_codex_env(); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::set_var("OPENAI_CODEX_MODEL", "gpt-5.1-codex"); + std::env::set_var("OPENAI_CODEX_BASE_URL", "https://custom.example.com/v1"); + std::env::set_var("OPENAI_CODEX_ACCOUNT_ID", "acct_123"); + std::env::set_var("CODEX_AUTH_PATH", "/tmp/test-auth.json"); + } + + let settings = Settings { + llm_backend: Some("openai_codex".to_string()), + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let codex = cfg.openai_codex.expect("codex config should be present"); + + assert_eq!(codex.model, "gpt-5.1-codex"); + assert_eq!(codex.base_url, "https://custom.example.com/v1"); + assert_eq!(codex.account_id.as_deref(), Some("acct_123")); + assert_eq!( + codex.auth_path, + std::path::PathBuf::from("/tmp/test-auth.json") + ); + + // Cleanup + unsafe { + std::env::remove_var("OPENAI_CODEX_MODEL"); + std::env::remove_var("OPENAI_CODEX_BASE_URL"); + std::env::remove_var("OPENAI_CODEX_ACCOUNT_ID"); + std::env::remove_var("CODEX_AUTH_PATH"); + } + } + + #[test] + fn codex_not_populated_for_other_backends() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_codex_env(); + + let settings = Settings { + llm_backend: Some("nearai".to_string()), + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + assert!(cfg.openai_codex.is_none()); + } + + #[test] + fn test_extract_codex_oauth_token_nested() { + let dir = std::env::temp_dir().join("ironclaw-test-codex"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("auth-nested.json"); + std::fs::write( + &path, + r#"{"tokens":{"access_token":"oauth-tok-123","refresh_token":"rt_456"}}"#, + ) + .expect("write test file"); + + let token = extract_codex_oauth_token(&path); + assert_eq!(token, Some("oauth-tok-123".to_string())); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_extract_codex_oauth_token_flat() { + let dir = std::env::temp_dir().join("ironclaw-test-codex"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("auth-flat.json"); + std::fs::write(&path, r#"{"token":"flat-tok-789"}"#).expect("write test file"); + + let token = extract_codex_oauth_token(&path); + assert_eq!(token, Some("flat-tok-789".to_string())); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_extract_codex_oauth_token_missing_file() { + let path = std::path::Path::new("/tmp/ironclaw-nonexistent-auth.json"); + assert!(extract_codex_oauth_token(path).is_none()); + } + + #[test] + fn test_extract_codex_oauth_token_empty_fields() { + let dir = std::env::temp_dir().join("ironclaw-test-codex"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("auth-empty.json"); + std::fs::write( + &path, + r#"{"tokens":{"access_token":""},"token":"","api_key":""}"#, + ) + .expect("write test file"); + + let token = extract_codex_oauth_token(&path); + assert!(token.is_none()); + + let _ = std::fs::remove_file(&path); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index a15dc505..641d63eb 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -37,8 +37,8 @@ pub use self::embeddings::EmbeddingsConfig; pub use self::heartbeat::HeartbeatConfig; pub use self::hygiene::HygieneConfig; pub use self::llm::{ - AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig, - OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, + AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig, OpenAiCodexConfig, + OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, extract_codex_oauth_token, }; pub use self::routines::RoutineConfig; pub use self::safety::SafetyConfig; @@ -220,6 +220,7 @@ pub async fn inject_llm_keys_from_secrets( ("llm_anthropic_api_key", "ANTHROPIC_API_KEY"), ("llm_compatible_api_key", "LLM_API_KEY"), ("llm_nearai_api_key", "NEARAI_API_KEY"), + ("llm_codex_api_key", "OPENAI_CODEX_API_KEY"), ]; let mut injected = HashMap::new(); diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 724f89f6..6f34adc6 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -11,6 +11,7 @@ pub mod circuit_breaker; pub mod costs; pub mod failover; mod nearai_chat; +pub mod openai_codex; mod provider; mod reasoning; pub mod response_cache; @@ -43,6 +44,7 @@ use secrecy::ExposeSecret; use crate::config::{LlmBackend, LlmConfig, NearAiConfig}; use crate::error::LlmError; +use crate::llm::openai_codex::OpenAiCodexProvider; /// Create an LLM provider based on configuration. /// @@ -60,6 +62,7 @@ pub fn create_llm_provider( LlmBackend::Ollama => create_ollama_provider(config), LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config), LlmBackend::Tinfoil => create_tinfoil_provider(config), + LlmBackend::OpenAiCodex => create_openai_codex_provider(config), } } @@ -265,6 +268,28 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result Result, LlmError> { + let codex = config + .openai_codex + .as_ref() + .ok_or_else(|| LlmError::AuthFailed { + provider: "openai_codex".to_string(), + })?; + + let auth_mode = if codex.api_key.is_some() { + "API key" + } else { + "OAuth (Codex CLI)" + }; + tracing::info!( + model = %codex.model, + base_url = %codex.base_url, + auth = auth_mode, + "Using OpenAI Codex (Responses API)" + ); + Ok(Arc::new(OpenAiCodexProvider::new(codex.clone())?)) +} + /// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation). /// /// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider. @@ -472,6 +497,7 @@ mod tests { ollama: None, openai_compatible: None, tinfoil: None, + openai_codex: None, } } diff --git a/src/llm/openai_codex.rs b/src/llm/openai_codex.rs new file mode 100644 index 00000000..dbd0eb6a --- /dev/null +++ b/src/llm/openai_codex.rs @@ -0,0 +1,1007 @@ +//! OpenAI Codex provider (Responses API). +//! +//! Supports two auth modes: +//! - **API key**: Standard OpenAI billing via `api.openai.com/v1/responses` +//! - **OAuth**: ChatGPT subscription billing via `chatgpt.com/backend-api/codex/responses`, +//! using tokens from the Codex CLI (`~/.codex/auth.json`) +//! +//! The Responses API has a fundamentally different wire format from Chat Completions: +//! flat `input` array, `instructions` instead of system messages, flat tool definitions +//! (no `function` wrapper nesting), and `output` array with typed items. + +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use reqwest::Client; +use rust_decimal::Decimal; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; + +use crate::config::OpenAiCodexConfig; +use crate::error::LlmError; +use crate::llm::costs; +use crate::llm::provider::{ + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, + ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, +}; + +// --------------------------------------------------------------------------- +// Responses API request types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ResponsesRequest { + model: String, + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +enum InputItem { + #[serde(rename = "message")] + Message { role: String, content: String }, + #[serde(rename = "function_call_output")] + FunctionCallOutput { call_id: String, output: String }, +} + +/// Flat tool definition for the Responses API. +/// +/// Unlike Chat Completions, tools are NOT nested under a `function` key. +#[derive(Serialize)] +struct CodexToolDef { + r#type: String, + name: String, + description: String, + parameters: serde_json::Value, +} + +// --------------------------------------------------------------------------- +// Responses API response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct ResponsesResponse { + #[allow(dead_code)] + #[serde(default)] + id: Option, + #[serde(default)] + output: Vec, + #[serde(default)] + usage: Option, + #[serde(default)] + status: Option, +} + +#[derive(Deserialize)] +#[serde(tag = "type")] +enum OutputItem { + #[serde(rename = "message")] + Message { + #[allow(dead_code)] + role: String, + content: Vec, + }, + #[serde(rename = "function_call")] + FunctionCall { + call_id: String, + name: String, + arguments: String, + }, +} + +#[derive(Deserialize)] +struct ContentBlock { + #[allow(dead_code)] + r#type: String, + text: String, +} + +#[derive(Deserialize, Default)] +struct ResponsesUsage { + #[serde(default)] + input_tokens: u32, + #[serde(default)] + output_tokens: u32, +} + +// --------------------------------------------------------------------------- +// Token management for OAuth mode +// --------------------------------------------------------------------------- + +struct CodexTokenManager { + auth_path: PathBuf, + current_token: tokio::sync::RwLock>, +} + +struct TokenState { + access_token: String, + #[allow(dead_code)] + refresh_token: Option, +} + +impl CodexTokenManager { + fn new(auth_path: PathBuf) -> Self { + Self { + auth_path, + current_token: tokio::sync::RwLock::new(None), + } + } + + /// Get a valid access token, loading from disk if not yet cached. + async fn get_token(&self) -> Result { + // Check cached token first + { + let guard = self.current_token.read().await; + if let Some(ref state) = *guard { + return Ok(state.access_token.clone()); + } + } + + // Load from disk + self.load_from_disk().await + } + + /// Load token from the auth.json file on disk. + async fn load_from_disk(&self) -> Result { + let path = self.auth_path.clone(); + let content = tokio::fs::read_to_string(&path) + .await + .map_err(|e| LlmError::AuthFailed { + provider: format!("openai_codex (cannot read {}): {}", path.display(), e), + })?; + + let json: serde_json::Value = + serde_json::from_str(&content).map_err(|e| LlmError::AuthFailed { + provider: format!("openai_codex (cannot parse {}): {}", path.display(), e), + })?; + + // Extract access token (try multiple field paths) + let access_token = json + .get("tokens") + .and_then(|t| t.get("access_token")) + .and_then(|v| v.as_str()) + .or_else(|| json.get("token").and_then(|v| v.as_str())) + .or_else(|| json.get("api_key").and_then(|v| v.as_str())) + .or_else(|| json.get("access_token").and_then(|v| v.as_str())) + .ok_or_else(|| LlmError::AuthFailed { + provider: format!("openai_codex (no token found in {})", path.display()), + })? + .to_string(); + + // Extract refresh token if available + let refresh_token = json + .get("tokens") + .and_then(|t| t.get("refresh_token")) + .and_then(|v| v.as_str()) + .or_else(|| json.get("refresh_token").and_then(|v| v.as_str())) + .map(String::from); + + let state = TokenState { + access_token: access_token.clone(), + refresh_token, + }; + + let mut guard = self.current_token.write().await; + *guard = Some(state); + + Ok(access_token) + } + + /// Clear cached token (forces reload on next get_token call). + async fn invalidate(&self) { + let mut guard = self.current_token.write().await; + *guard = None; + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/// Known Codex models for the wizard (OAuth tokens can't call /v1/models). +pub const CODEX_MODELS: &[(&str, &str)] = &[ + ("gpt-5.3-codex", "GPT-5.3 Codex (flagship)"), + ("gpt-5.3-codex-spark", "GPT-5.3 Codex Spark (fast)"), + ("gpt-5.2-codex", "GPT-5.2 Codex"), + ("gpt-5.1-codex", "GPT-5.1 Codex"), + ("gpt-5.1-codex-mini", "GPT-5.1 Codex Mini"), + ("gpt-5-codex", "GPT-5 Codex"), + ("o3", "o3 (reasoning)"), + ("o4-mini", "o4-mini (reasoning)"), +]; + +/// OpenAI Codex provider using the Responses API. +pub struct OpenAiCodexProvider { + client: Client, + config: OpenAiCodexConfig, + /// Token manager for OAuth mode. `None` when using API key. + token_manager: Option>, + active_model: std::sync::RwLock, +} + +impl OpenAiCodexProvider { + /// Create a new Codex provider. + pub fn new(config: OpenAiCodexConfig) -> Result { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(180)) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "openai_codex".to_string(), + reason: format!("Failed to build HTTP client: {}", e), + })?; + + let token_manager = if config.api_key.is_none() { + Some(Arc::new(CodexTokenManager::new(config.auth_path.clone()))) + } else { + None + }; + + let active_model = std::sync::RwLock::new(config.model.clone()); + + Ok(Self { + client, + config, + token_manager, + active_model, + }) + } + + /// Build the full URL for the Responses API endpoint. + fn responses_url(&self) -> String { + let base = self.config.base_url.trim_end_matches('/'); + if base.ends_with("/v1") { + format!("{}/responses", base) + } else if base.contains("chatgpt.com") { + // ChatGPT endpoint: base is already .../codex + format!("{}/responses", base) + } else { + format!("{}/v1/responses", base) + } + } + + /// Whether we're using API key auth (vs OAuth). + fn uses_api_key(&self) -> bool { + self.config.api_key.is_some() + } + + /// Resolve the Bearer token for the current auth mode. + async fn resolve_bearer_token(&self) -> Result { + if let Some(ref api_key) = self.config.api_key { + Ok(api_key.expose_secret().to_string()) + } else if let Some(ref tm) = self.token_manager { + tm.get_token().await + } else { + Err(LlmError::AuthFailed { + provider: "openai_codex".to_string(), + }) + } + } + + /// Send a request to the Responses API, with 401 retry for OAuth mode. + async fn send_request(&self, body: &ResponsesRequest) -> Result { + match self.send_request_inner(body).await { + Ok(result) => Ok(result), + Err(LlmError::AuthFailed { .. }) if !self.uses_api_key() => { + // OAuth token may have expired — reload from disk and retry once + if let Some(ref tm) = self.token_manager { + tm.invalidate().await; + } + self.send_request_inner(body).await + } + Err(e) => Err(e), + } + } + + /// Inner request implementation (single attempt). + async fn send_request_inner( + &self, + body: &ResponsesRequest, + ) -> Result { + let url = self.responses_url(); + let token = self.resolve_bearer_token().await?; + + tracing::debug!("Sending request to OpenAI Codex: {}", url); + + let mut req = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json"); + + // Add account ID header for ChatGPT endpoint + if let Some(ref account_id) = self.config.account_id { + req = req.header("openai-account-id", account_id); + } + + let response = req + .json(body) + .send() + .await + .map_err(|e| LlmError::RequestFailed { + provider: "openai_codex".to_string(), + reason: e.to_string(), + })?; + + let status = response.status(); + let response_text = response.text().await.map_err(|e| LlmError::RequestFailed { + provider: "openai_codex".to_string(), + reason: format!("Failed to read response body: {}", e), + })?; + + tracing::debug!("OpenAI Codex response status: {}", status); + tracing::debug!("OpenAI Codex response body: {}", response_text); + + if !status.is_success() { + let status_code = status.as_u16(); + + if status_code == 401 { + return Err(LlmError::AuthFailed { + provider: "openai_codex".to_string(), + }); + } + + if status_code == 429 { + return Err(LlmError::RateLimited { + provider: "openai_codex".to_string(), + retry_after: None, + }); + } + + let truncated = crate::agent::truncate_for_preview(&response_text, 512); + return Err(LlmError::RequestFailed { + provider: "openai_codex".to_string(), + reason: format!("HTTP {}: {}", status, truncated), + }); + } + + serde_json::from_str(&response_text).map_err(|e| { + let truncated = crate::agent::truncate_for_preview(&response_text, 512); + LlmError::InvalidResponse { + provider: "openai_codex".to_string(), + reason: format!("JSON parse error: {}. Raw: {}", e, truncated), + } + }) + } +} + +// --------------------------------------------------------------------------- +// Message / tool conversion +// --------------------------------------------------------------------------- + +/// Convert IronClaw messages to Responses API format. +/// +/// System messages are extracted into a single `instructions` string. +/// User/Assistant messages become `InputItem::Message`. +/// Tool result messages become `InputItem::FunctionCallOutput`. +fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec) { + let mut instructions_parts: Vec = Vec::new(); + let mut input: Vec = Vec::new(); + + for msg in messages { + match msg.role { + Role::System => { + instructions_parts.push(msg.content.clone()); + } + Role::User => { + input.push(InputItem::Message { + role: "user".to_string(), + content: msg.content.clone(), + }); + } + Role::Assistant => { + if !msg.content.is_empty() { + input.push(InputItem::Message { + role: "assistant".to_string(), + content: msg.content.clone(), + }); + } + } + Role::Tool => { + if let Some(ref call_id) = msg.tool_call_id { + input.push(InputItem::FunctionCallOutput { + call_id: call_id.clone(), + output: msg.content.clone(), + }); + } else { + tracing::warn!( + "Skipping tool message without tool_call_id (tool: {:?})", + msg.name + ); + } + } + } + } + + let instructions = if instructions_parts.is_empty() { + None + } else { + Some(instructions_parts.join("\n\n")) + }; + + (instructions, input) +} + +/// Convert IronClaw tool definitions to Responses API flat format. +fn convert_tools(tools: &[ToolDefinition]) -> Vec { + tools + .iter() + .map(|t| CodexToolDef { + r#type: "function".to_string(), + name: t.name.clone(), + description: t.description.clone(), + parameters: t.parameters.clone(), + }) + .collect() +} + +/// Parse output items into text content and tool calls. +fn parse_output(output: Vec) -> (Option, Vec) { + let mut text_parts: Vec = Vec::new(); + let mut tool_calls: Vec = Vec::new(); + + for item in output { + match item { + OutputItem::Message { content, .. } => { + for block in content { + if !block.text.is_empty() { + text_parts.push(block.text); + } + } + } + OutputItem::FunctionCall { + call_id, + name, + arguments, + } => { + let args = serde_json::from_str(&arguments) + .unwrap_or(serde_json::Value::Object(Default::default())); + tool_calls.push(ToolCall { + id: call_id, + name, + arguments: args, + }); + } + } + } + + let content = if text_parts.is_empty() { + None + } else { + Some(text_parts.join("")) + }; + + (content, tool_calls) +} + +/// Map Responses API status to FinishReason. +fn map_status(status: Option<&str>, has_tool_calls: bool) -> FinishReason { + match status { + Some("completed") => FinishReason::Stop, + Some("incomplete") => FinishReason::Length, + Some("failed") => FinishReason::Unknown, + _ => { + if has_tool_calls { + FinishReason::ToolUse + } else { + FinishReason::Unknown + } + } + } +} + +// --------------------------------------------------------------------------- +// LlmProvider implementation +// --------------------------------------------------------------------------- + +#[async_trait] +impl LlmProvider for OpenAiCodexProvider { + fn model_name(&self) -> &str { + &self.config.model + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + let model = self.active_model_name(); + costs::model_cost(&model).unwrap_or_else(costs::default_cost) + } + + async fn complete(&self, req: CompletionRequest) -> Result { + let model = req.model.unwrap_or_else(|| self.active_model_name()); + let (instructions, input) = convert_messages(&req.messages); + + let request = ResponsesRequest { + model, + instructions, + input, + tools: None, + max_output_tokens: req.max_tokens, + temperature: req.temperature, + }; + + let response = self.send_request(&request).await?; + let (input_tokens, output_tokens) = match response.usage { + Some(u) => (u.input_tokens, u.output_tokens), + None => (0, 0), + }; + + let (content, _) = parse_output(response.output); + let finish_reason = map_status(response.status.as_deref(), false); + + Ok(CompletionResponse { + content: content.unwrap_or_default(), + input_tokens, + output_tokens, + finish_reason, + }) + } + + async fn complete_with_tools( + &self, + req: ToolCompletionRequest, + ) -> Result { + let model = req.model.unwrap_or_else(|| self.active_model_name()); + let (instructions, input) = convert_messages(&req.messages); + let tools = convert_tools(&req.tools); + + let request = ResponsesRequest { + model, + instructions, + input, + tools: if tools.is_empty() { None } else { Some(tools) }, + max_output_tokens: req.max_tokens, + temperature: req.temperature, + }; + + let response = self.send_request(&request).await?; + let (input_tokens, output_tokens) = match response.usage { + Some(u) => (u.input_tokens, u.output_tokens), + None => (0, 0), + }; + + let (content, tool_calls) = parse_output(response.output); + let finish_reason = map_status(response.status.as_deref(), !tool_calls.is_empty()); + + Ok(ToolCompletionResponse { + content, + tool_calls, + input_tokens, + output_tokens, + finish_reason, + }) + } + + async fn list_models(&self) -> Result, LlmError> { + Ok(CODEX_MODELS.iter().map(|(id, _)| id.to_string()).collect()) + } + + fn active_model_name(&self) -> String { + match self.active_model.read() { + Ok(guard) => guard.clone(), + Err(poisoned) => { + tracing::warn!("active_model lock poisoned while reading; continuing"); + poisoned.into_inner().clone() + } + } + } + + fn set_model(&self, model: &str) -> Result<(), LlmError> { + match self.active_model.write() { + Ok(mut guard) => { + *guard = model.to_string(); + } + Err(poisoned) => { + tracing::warn!("active_model lock poisoned while writing; continuing"); + *poisoned.into_inner() = model.to_string(); + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_messages_system_to_instructions() { + let messages = vec![ + ChatMessage::system("You are helpful."), + ChatMessage::system("Be concise."), + ChatMessage::user("Hello"), + ]; + + let (instructions, input) = convert_messages(&messages); + assert_eq!( + instructions, + Some("You are helpful.\n\nBe concise.".to_string()) + ); + assert_eq!(input.len(), 1); + + // Verify it's a user message + match &input[0] { + InputItem::Message { role, content } => { + assert_eq!(role, "user"); + assert_eq!(content, "Hello"); + } + _ => panic!("Expected Message"), + } + } + + #[test] + fn test_convert_messages_tool_result() { + let messages = vec![ChatMessage::tool_result( + "call_123", + "my_tool", + "result data", + )]; + + let (instructions, input) = convert_messages(&messages); + assert!(instructions.is_none()); + assert_eq!(input.len(), 1); + + match &input[0] { + InputItem::FunctionCallOutput { call_id, output } => { + assert_eq!(call_id, "call_123"); + assert_eq!(output, "result data"); + } + _ => panic!("Expected FunctionCallOutput"), + } + } + + #[test] + fn test_convert_messages_skips_tool_without_call_id() { + let msg = ChatMessage { + role: Role::Tool, + content: "orphan result".to_string(), + tool_call_id: None, + name: Some("broken_tool".to_string()), + tool_calls: None, + }; + + let (_, input) = convert_messages(&[msg]); + assert!(input.is_empty()); + } + + #[test] + fn test_convert_messages_full_conversation() { + let messages = vec![ + ChatMessage::system("Be helpful"), + ChatMessage::user("What time is it?"), + ChatMessage::assistant("Let me check."), + ChatMessage::tool_result("call_1", "time", "14:30"), + ]; + + let (instructions, input) = convert_messages(&messages); + assert_eq!(instructions, Some("Be helpful".to_string())); + assert_eq!(input.len(), 3); + + match &input[0] { + InputItem::Message { role, .. } => assert_eq!(role, "user"), + _ => panic!("Expected Message"), + } + match &input[1] { + InputItem::Message { role, .. } => assert_eq!(role, "assistant"), + _ => panic!("Expected Message"), + } + match &input[2] { + InputItem::FunctionCallOutput { call_id, .. } => assert_eq!(call_id, "call_1"), + _ => panic!("Expected FunctionCallOutput"), + } + } + + #[test] + fn test_convert_tools_flat_format() { + let tools = vec![ + ToolDefinition { + name: "search".to_string(), + description: "Search the web".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }), + }, + ToolDefinition { + name: "read".to_string(), + description: "Read a file".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string" } + } + }), + }, + ]; + + let converted = convert_tools(&tools); + assert_eq!(converted.len(), 2); + assert_eq!(converted[0].r#type, "function"); + assert_eq!(converted[0].name, "search"); + assert_eq!(converted[1].name, "read"); + + // Verify flat format by serializing + let json = serde_json::to_value(&converted[0]).expect("serialize"); + assert!(json.get("type").is_some()); + assert!(json.get("name").is_some()); + // Should NOT have a nested "function" key + assert!(json.get("function").is_none()); + } + + #[test] + fn test_parse_output_message() { + let output = vec![OutputItem::Message { + role: "assistant".to_string(), + content: vec![ContentBlock { + r#type: "output_text".to_string(), + text: "Hello world".to_string(), + }], + }]; + + let (content, tool_calls) = parse_output(output); + assert_eq!(content, Some("Hello world".to_string())); + assert!(tool_calls.is_empty()); + } + + #[test] + fn test_parse_output_function_call() { + let output = vec![OutputItem::FunctionCall { + call_id: "call_abc".to_string(), + name: "search".to_string(), + arguments: r#"{"query":"test"}"#.to_string(), + }]; + + let (content, tool_calls) = parse_output(output); + assert!(content.is_none()); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].id, "call_abc"); + assert_eq!(tool_calls[0].name, "search"); + assert_eq!(tool_calls[0].arguments["query"], "test"); + } + + #[test] + fn test_parse_output_mixed() { + let output = vec![ + OutputItem::Message { + role: "assistant".to_string(), + content: vec![ContentBlock { + r#type: "output_text".to_string(), + text: "I'll search for that.".to_string(), + }], + }, + OutputItem::FunctionCall { + call_id: "call_1".to_string(), + name: "search".to_string(), + arguments: r#"{"q":"rust"}"#.to_string(), + }, + ]; + + let (content, tool_calls) = parse_output(output); + assert_eq!(content, Some("I'll search for that.".to_string())); + assert_eq!(tool_calls.len(), 1); + } + + #[test] + fn test_map_status() { + assert_eq!(map_status(Some("completed"), false), FinishReason::Stop); + assert_eq!(map_status(Some("incomplete"), false), FinishReason::Length); + assert_eq!(map_status(Some("failed"), false), FinishReason::Unknown); + assert_eq!(map_status(None, true), FinishReason::ToolUse); + assert_eq!(map_status(None, false), FinishReason::Unknown); + } + + #[test] + fn test_responses_request_serialization() { + let req = ResponsesRequest { + model: "gpt-5.3-codex".to_string(), + instructions: Some("Be helpful".to_string()), + input: vec![InputItem::Message { + role: "user".to_string(), + content: "Hello".to_string(), + }], + tools: None, + max_output_tokens: None, + temperature: None, + }; + + let json = serde_json::to_value(&req).expect("serialize"); + assert_eq!(json["model"], "gpt-5.3-codex"); + assert_eq!(json["instructions"], "Be helpful"); + assert_eq!(json["input"][0]["type"], "message"); + assert_eq!(json["input"][0]["role"], "user"); + assert!(json.get("tools").is_none()); // skip_serializing_if + } + + #[test] + fn test_responses_response_deserialization() { + let json = serde_json::json!({ + "id": "resp_123", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "Hello!" } + ] + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 5 + }, + "status": "completed" + }); + + let resp: ResponsesResponse = serde_json::from_value(json).expect("deserialize"); + assert_eq!(resp.status, Some("completed".to_string())); + assert_eq!(resp.output.len(), 1); + assert_eq!(resp.usage.as_ref().map(|u| u.input_tokens), Some(10)); + } + + #[test] + fn test_responses_response_with_function_calls() { + let json = serde_json::json!({ + "id": "resp_456", + "output": [ + { + "type": "function_call", + "call_id": "call_xyz", + "name": "read_file", + "arguments": "{\"path\": \"/tmp/test.txt\"}" + } + ], + "usage": { + "input_tokens": 20, + "output_tokens": 15 + }, + "status": "completed" + }); + + let resp: ResponsesResponse = serde_json::from_value(json).expect("deserialize"); + assert_eq!(resp.output.len(), 1); + match &resp.output[0] { + OutputItem::FunctionCall { + call_id, + name, + arguments, + } => { + assert_eq!(call_id, "call_xyz"); + assert_eq!(name, "read_file"); + assert!(arguments.contains("test.txt")); + } + _ => panic!("Expected FunctionCall"), + } + } + + #[test] + fn test_input_item_serialization() { + let msg = InputItem::Message { + role: "user".to_string(), + content: "hi".to_string(), + }; + let json = serde_json::to_value(&msg).expect("serialize"); + assert_eq!(json["type"], "message"); + assert_eq!(json["role"], "user"); + + let fc = InputItem::FunctionCallOutput { + call_id: "call_1".to_string(), + output: "result".to_string(), + }; + let json = serde_json::to_value(&fc).expect("serialize"); + assert_eq!(json["type"], "function_call_output"); + assert_eq!(json["call_id"], "call_1"); + } + + #[test] + fn test_codex_models_list() { + assert!(!CODEX_MODELS.is_empty()); + assert!(CODEX_MODELS.iter().any(|(id, _)| *id == "gpt-5.3-codex")); + } + + fn test_config_api_key() -> OpenAiCodexConfig { + OpenAiCodexConfig { + model: "gpt-5.3-codex".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + api_key: Some(secrecy::SecretString::from("sk-test")), + auth_path: std::path::PathBuf::from("/tmp/nonexistent-auth.json"), + account_id: None, + } + } + + fn test_config_oauth() -> OpenAiCodexConfig { + OpenAiCodexConfig { + model: "gpt-5.3-codex".to_string(), + base_url: "https://chatgpt.com/backend-api/codex".to_string(), + api_key: None, + auth_path: std::path::PathBuf::from("/tmp/nonexistent-auth.json"), + account_id: Some("acct_123".to_string()), + } + } + + #[test] + fn test_provider_creates_with_api_key() { + let provider = OpenAiCodexProvider::new(test_config_api_key()).expect("create provider"); + assert_eq!(provider.model_name(), "gpt-5.3-codex"); + assert!(provider.uses_api_key()); + assert!(provider.token_manager.is_none()); + } + + #[test] + fn test_provider_creates_with_oauth() { + let provider = OpenAiCodexProvider::new(test_config_oauth()).expect("create provider"); + assert!(!provider.uses_api_key()); + assert!(provider.token_manager.is_some()); + } + + #[test] + fn test_responses_url_api_key_mode() { + let provider = OpenAiCodexProvider::new(test_config_api_key()).expect("create provider"); + assert_eq!( + provider.responses_url(), + "https://api.openai.com/v1/responses" + ); + } + + #[test] + fn test_responses_url_oauth_mode() { + let provider = OpenAiCodexProvider::new(test_config_oauth()).expect("create provider"); + assert_eq!( + provider.responses_url(), + "https://chatgpt.com/backend-api/codex/responses" + ); + } + + #[test] + fn test_responses_url_custom_base() { + let config = OpenAiCodexConfig { + base_url: "https://custom.example.com/v1".to_string(), + ..test_config_api_key() + }; + let provider = OpenAiCodexProvider::new(config).expect("create provider"); + assert_eq!( + provider.responses_url(), + "https://custom.example.com/v1/responses" + ); + } + + #[test] + fn test_set_model_and_active_model() { + let provider = OpenAiCodexProvider::new(test_config_api_key()).expect("create provider"); + assert_eq!(provider.active_model_name(), "gpt-5.3-codex"); + + provider.set_model("gpt-5.1-codex").expect("set model"); + assert_eq!(provider.active_model_name(), "gpt-5.1-codex"); + // model_name() still returns the original config model + assert_eq!(provider.model_name(), "gpt-5.3-codex"); + } + + #[test] + fn test_cost_per_token_known_model() { + let provider = OpenAiCodexProvider::new(test_config_api_key()).expect("create provider"); + let (input_cost, output_cost) = provider.cost_per_token(); + // gpt-5.3-codex is in costs.rs + assert!(input_cost > Decimal::ZERO); + assert!(output_cost > Decimal::ZERO); + } + + #[tokio::test] + async fn test_list_models_returns_hardcoded() { + let provider = OpenAiCodexProvider::new(test_config_api_key()).expect("create provider"); + let models = provider.list_models().await.expect("list models"); + assert!(!models.is_empty()); + assert!(models.contains(&"gpt-5.3-codex".to_string())); + } +} diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 3662a653..f9aa0d1b 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -751,6 +751,7 @@ impl SetupWizard { "openai" => "OpenAI", "ollama" => "Ollama (local)", "openai_compatible" => "OpenAI-compatible endpoint", + "openai_codex" => "OpenAI Codex (Responses API)", other => other, } }; @@ -759,7 +760,7 @@ impl SetupWizard { let is_known = matches!( current.as_str(), - "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" + "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" | "openai_codex" ); if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? { @@ -773,6 +774,7 @@ impl SetupWizard { "openai" => return self.setup_openai().await, "ollama" => return self.setup_ollama(), "openai_compatible" => return self.setup_openai_compatible().await, + "openai_codex" => return self.setup_openai_codex().await, _ => { return Err(SetupError::Config(format!( "Unhandled provider: {}", @@ -800,6 +802,7 @@ impl SetupWizard { "Ollama - local models, no API key needed", "OpenRouter - 200+ models via single API key", "OpenAI-compatible - custom endpoint (vLLM, LiteLLM, etc.)", + "OpenAI Codex - Responses API (ChatGPT OAuth or API key)", ]; let choice = select_one("Provider:", options).map_err(SetupError::Io)?; @@ -811,6 +814,7 @@ impl SetupWizard { 3 => self.setup_ollama()?, 4 => self.setup_openrouter().await?, 5 => self.setup_openai_compatible().await?, + 6 => self.setup_openai_codex().await?, _ => return Err(SetupError::Config("Invalid provider selection".to_string())), } @@ -1066,6 +1070,100 @@ impl SetupWizard { Ok(()) } + /// OpenAI Codex provider setup: API key or Codex CLI OAuth. + async fn setup_openai_codex(&mut self) -> Result<(), SetupError> { + self.settings.llm_backend = Some("openai_codex".to_string()); + if self.settings.selected_model.is_some() { + self.settings.selected_model = None; + } + + let auth_options = &[ + "API key - standard OpenAI billing (api.openai.com)", + "Codex CLI OAuth - ChatGPT subscription billing (~/.codex/auth.json)", + ]; + + let auth_choice = + select_one("Authentication mode:", auth_options).map_err(SetupError::Io)?; + + match auth_choice { + 0 => { + // API key mode — delegate to shared helper + self.setup_api_key_provider( + "openai_codex", + "OPENAI_CODEX_API_KEY", + "llm_codex_api_key", + "OpenAI API key (for Codex)", + "https://platform.openai.com/api-keys", + Some("OpenAI Codex"), + ) + .await?; + } + 1 => { + // OAuth mode — read from Codex CLI auth.json + let auth_path = std::env::var("CODEX_AUTH_PATH").unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".codex") + .join("auth.json") + .to_string_lossy() + .to_string() + }); + + let path = std::path::Path::new(&auth_path); + match crate::config::extract_codex_oauth_token(path) { + Some(token) => { + print_info(&format!( + "Found Codex OAuth token: {}", + mask_api_key(&token) + )); + if !confirm("Use this token?", true).map_err(SetupError::Io)? { + return Err(SetupError::Cancelled); + } + print_success("OpenAI Codex configured (OAuth from Codex CLI)"); + } + None => { + print_error(&format!("No Codex OAuth token found at {}", path.display())); + print_info( + "Run `npx codex --full-setup` to authenticate, then retry setup.", + ); + if confirm("Retry after authenticating?", true).map_err(SetupError::Io)? { + // Check again after user's action + match crate::config::extract_codex_oauth_token(path) { + Some(_) => { + print_success("OpenAI Codex configured (OAuth from Codex CLI)"); + } + None => { + return Err(SetupError::Auth(format!( + "Still no token found at {}. Run Codex CLI setup first.", + path.display() + ))); + } + } + } else { + return Err(SetupError::Cancelled); + } + } + } + + // Prompt for account ID (required for ChatGPT endpoint) + let account_id = optional_input( + "OpenAI account ID (for ChatGPT endpoint, optional)", + Some("leave blank if unknown"), + ) + .map_err(SetupError::Io)?; + + if let Some(ref id) = account_id + && !id.is_empty() + { + print_info(&format!("Account ID: {}", id)); + } + } + _ => return Err(SetupError::Config("Invalid auth choice".to_string())), + } + + Ok(()) + } + /// Step 4: Model selection. /// /// Branches on the selected LLM backend and fetches models from the @@ -1127,6 +1225,14 @@ impl SetupWizard { self.settings.selected_model = Some(model_id.clone()); print_success(&format!("Selected {}", model_id)); } + "openai_codex" => { + // OAuth tokens can't call /v1/models — use hardcoded list + let models: Vec<(String, String)> = crate::llm::openai_codex::CODEX_MODELS + .iter() + .map(|(id, desc)| (id.to_string(), desc.to_string())) + .collect(); + self.select_from_model_list(&models)?; + } _ => { // NEAR AI: use existing provider list_models() let fetched = self.fetch_nearai_models().await; @@ -1230,6 +1336,7 @@ impl SetupWizard { ollama: None, openai_compatible: None, tinfoil: None, + openai_codex: None, }; match create_llm_provider(&config, session) {