Compare commits

...
Author SHA1 Message Date
serrrfiratandClaude Opus 4.6 bcae0852df 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 <[email protected]>
2026-02-27 07:46:27 +04:00
6 changed files with 1420 additions and 5 deletions
+13 -1
View File
@@ -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
+263 -1
View File
@@ -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<SecretString>,
/// 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<String>,
}
/// 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<OpenAiCompatibleConfig>,
/// Tinfoil config (populated when backend=tinfoil)
pub tinfoil: Option<TinfoilConfig>,
/// OpenAI Codex config (populated when backend=openai_codex)
pub openai_codex: Option<OpenAiCodexConfig>,
}
/// 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<Vec<(String, String)>, 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<String> {
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);
}
}
+3 -2
View File
@@ -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();
+26
View File
@@ -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<Arc<dyn LlmPr
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
}
fn create_openai_codex_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, 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,
}
}
File diff suppressed because it is too large Load Diff
+108 -1
View File
@@ -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) {