Merge pull request #1466 from nearai/staging-promote/3da9810e-23351687636

chore: promote staging to staging-promote/cba1bc37-23334371795 (2026-03-20 16:12 UTC)
This commit is contained in:
Henry Park
2026-03-23 12:00:31 -07:00
committed by GitHub
20 changed files with 2477 additions and 14 deletions
+8 -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
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
# === Anthropic Direct ===
@@ -92,6 +92,13 @@ NEARAI_AUTH_URL=https://private.near.ai
# long = 1-hour TTL, 2.0× (200%) write surcharge
# ANTHROPIC_CACHE_RETENTION=short
# === OpenAI Codex (ChatGPT subscription, OAuth) ===
# LLM_BACKEND=openai_codex
# OPENAI_CODEX_MODEL=gpt-5.3-codex # default
# OPENAI_CODEX_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # override (rare)
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
# For full provider setup guide see docs/LLM_PROVIDERS.md
# Channel Configuration
+1
View File
@@ -696,6 +696,7 @@ impl AppBuilder {
// fail early with a clear error instead of a confusing runtime failure.
if self.config.llm.backend != "nearai"
&& self.config.llm.backend != "bedrock"
&& self.config.llm.backend != "openai_codex"
&& self.config.llm.provider.is_none()
{
let backend = &self.config.llm.backend;
+11
View File
@@ -239,6 +239,17 @@ pub enum Command {
)]
Import(ImportCommand),
/// Authenticate with a provider (re-login)
#[command(
about = "Authenticate with a provider",
long_about = "Re-authenticate with an LLM provider.\nExample: ironclaw login --openai-codex"
)]
Login {
/// Authenticate with OpenAI Codex (ChatGPT subscription)
#[arg(long)]
openai_codex: bool,
},
/// Run as a sandboxed worker inside a Docker container (internal use).
/// This is invoked automatically by the orchestrator, not by users directly.
#[command(hide = true)]
@@ -24,6 +24,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -23,6 +23,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -27,6 +27,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -26,6 +26,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
+198 -3
View File
@@ -37,6 +37,7 @@ impl LlmConfig {
},
provider: None,
bedrock: None,
openai_codex: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
@@ -72,8 +73,12 @@ impl LlmConfig {
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
let is_bedrock =
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
let is_openai_codex = backend_lower == "openai_codex"
|| backend_lower == "openai-codex"
|| backend_lower == "codex";
if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() {
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
{
tracing::warn!(
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
backend
@@ -126,8 +131,8 @@ impl LlmConfig {
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
};
// Resolve registry provider config (for non-NearAI, non-Bedrock backends)
let provider = if is_nearai || is_bedrock {
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
let provider = if is_nearai || is_bedrock || is_openai_codex {
None
} else {
Some(Self::resolve_registry_provider(
@@ -174,6 +179,38 @@ impl LlmConfig {
None
};
// Resolve OpenAI Codex config
let openai_codex = if is_openai_codex {
// Model: OPENAI_CODEX_MODEL > OPENAI_MODEL > settings.selected_model > default
let model = optional_env("OPENAI_CODEX_MODEL")?
.or(optional_env("OPENAI_MODEL")?)
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| "gpt-5.3-codex".to_string());
let auth_endpoint = optional_env("OPENAI_CODEX_AUTH_URL")?
.unwrap_or_else(|| "https://auth.openai.com".to_string());
validate_base_url(&auth_endpoint, "OPENAI_CODEX_AUTH_URL")?;
let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
validate_base_url(&api_base_url, "OPENAI_CODEX_API_URL")?;
let client_id = optional_env("OPENAI_CODEX_CLIENT_ID")?
.unwrap_or_else(|| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
let session_path = optional_env("OPENAI_CODEX_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(|| ironclaw_base_dir().join("openai_codex_session.json"));
let token_refresh_margin_secs =
parse_optional_env("OPENAI_CODEX_REFRESH_MARGIN_SECS", 300)?;
Some(OpenAiCodexConfig {
model,
auth_endpoint,
api_base_url,
client_id,
session_path,
token_refresh_margin_secs,
})
} else {
None
};
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
// Generic cheap model (works with any backend).
@@ -189,6 +226,8 @@ impl LlmConfig {
"nearai".to_string()
} else if is_bedrock {
"bedrock".to_string()
} else if is_openai_codex {
"openai_codex".to_string()
} else if let Some(ref p) = provider {
p.provider_id.clone()
} else {
@@ -198,6 +237,7 @@ impl LlmConfig {
nearai,
provider,
bedrock,
openai_codex,
request_timeout_secs,
cheap_model,
smart_routing_cascade,
@@ -1069,4 +1109,159 @@ mod tests {
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
}
}
// ── OpenAI Codex tests ──────────────────────────────────────────
/// Clear all openai-codex-related env vars.
fn clear_openai_codex_env() {
// SAFETY: Only called under ENV_MUTEX in tests.
unsafe {
std::env::remove_var("LLM_BACKEND");
std::env::remove_var("OPENAI_CODEX_MODEL");
std::env::remove_var("OPENAI_MODEL");
}
}
#[test]
fn openai_codex_resolves_config() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!(cfg.backend, "openai_codex");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "gpt-5.3-codex"); // default
assert!(
cfg.provider.is_none(),
"codex should not use registry provider"
);
}
#[test]
fn openai_codex_model_env_resolution() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_CODEX_MODEL", "o3-pro");
}
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, "o3-pro");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_MODEL");
}
}
#[test]
fn openai_codex_falls_back_to_openai_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_MODEL", "gpt-4o");
}
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-4o");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_MODEL");
}
}
#[test]
fn openai_codex_falls_back_to_selected_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
selected_model: Some("gpt-4o-mini".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-4o-mini");
}
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
#[test]
fn openai_codex_rejects_ssrf_api_url() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var(
"OPENAI_CODEX_API_URL",
"http://169.254.169.254/latest/meta-data",
);
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let err = LlmConfig::resolve(&settings).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("OPENAI_CODEX_API_URL"),
"error should reference the field name: {msg}"
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_API_URL");
}
}
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
#[test]
fn openai_codex_rejects_ssrf_auth_url() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_CODEX_AUTH_URL", "http://10.0.0.1");
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let err = LlmConfig::resolve(&settings).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("OPENAI_CODEX_AUTH_URL"),
"error should reference the field name: {msg}"
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_AUTH_URL");
}
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ pub use self::transcription::TranscriptionConfig;
pub use self::tunnel::TunnelConfig;
pub use self::wasm::WasmConfig;
pub use crate::llm::config::{
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
};
pub use crate::llm::session::SessionConfig;
@@ -377,7 +377,7 @@ pub(crate) fn resolve_owner_id(settings: &Settings) -> Result<String, ConfigErro
/// are read by `optional_env()` before falling back to `std::env::var()`,
/// so explicit env vars always win.
///
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
/// Also loads tokens from OS credential stores (macOS Keychain / Linux
/// credentials files) which don't require the secrets DB.
pub async fn inject_llm_keys_from_secrets(
secrets: &dyn crate::secrets::SecretsStore,
+23 -1
View File
@@ -13,6 +13,9 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
| `codex_auth.rs` | Reads Codex CLI `auth.json`, extracts tokens, refreshes ChatGPT OAuth access tokens |
| `codex_chatgpt.rs` | Custom Responses API provider for Codex ChatGPT backend (`/backend-api/codex`) |
| `openai_codex_provider.rs` | OpenAI Codex Responses API client (SSE streaming, JWT auth, subscription billing) |
| `openai_codex_session.rs` | OAuth 2.0 session manager for OpenAI Codex (device code flow, token persistence) |
| `token_refreshing.rs` | Token-refreshing `LlmProvider` decorator for OpenAI Codex (pre-emptive refresh, zero-cost billing) |
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
@@ -38,6 +41,7 @@ Set via `LLM_BACKEND` env var:
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
| `openai_codex` | OpenAI Codex (ChatGPT subscription) | `OPENAI_CODEX_MODEL`, `OPENAI_CODEX_CLIENT_ID` |
Codex auth reuse:
- Set `LLM_USE_CODEX_AUTH=true` to load credentials from `~/.codex/auth.json` (override with `CODEX_AUTH_PATH`).
@@ -148,9 +152,27 @@ To add a new provider:
Set `LLM_EXTRA_HEADERS=Key:Value,Key2:Value2` to inject headers into every request. Useful for OpenRouter attribution (`HTTP-Referer`, `X-Title`). Invalid header names/values are skipped with a warning (not a fatal error).
## OpenAI Codex Provider
Uses the Responses API at `chatgpt.com/backend-api/codex/responses` with ChatGPT subscription OAuth tokens (zero API cost — billing through subscription).
**Auth flow:** Device code OAuth via `auth.openai.com/api/accounts/deviceauth/*` endpoints. On first run, displays a code for the user to enter at a URL. Tokens are persisted to `~/.ironclaw/openai_codex_session.json` (mode 0600) and auto-refreshed before expiry.
**Provider chain:** `OpenAiCodexProvider``TokenRefreshingProvider` (pre-emptive refresh + retry on 401) → standard decorator chain. The `TokenRefreshingProvider` intercepts `AuthFailed`/`SessionExpired` errors, refreshes the OAuth token, and retries once.
**Key differences from other providers:**
- Uses Responses API (not Chat Completions) — SSE streaming with different event types
- System messages are sent as `instructions` field, not in `input` array
- Tool schemas are normalized via `normalize_schema_strict()` for OpenAI strict mode
- `cost_per_token()` returns `(0, 0)` — subscription-based billing
- `set_model()` returns error — model is fixed at construction time
- Image attachments are silently dropped with a warning log
**Env vars:** `OPENAI_CODEX_MODEL` (default: `gpt-5.3-codex`), `OPENAI_CODEX_CLIENT_ID`, `OPENAI_CODEX_AUTH_URL`, `OPENAI_CODEX_API_URL`.
## Provider Chain Construction
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. The chain is:
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. It creates the base provider (dispatching to `create_openai_codex_provider()` for codex, `create_llm_provider()` for everything else), then applies all decorators inline:
```
Raw provider
+34
View File
@@ -0,0 +1,34 @@
//! Shared test helpers for OpenAI Codex provider tests.
#![cfg(test)]
use crate::config::OpenAiCodexConfig;
/// Build a minimal JWT for testing (header.payload.signature).
pub(crate) fn make_test_jwt(account_id: &str) -> String {
use base64::Engine;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let header = engine.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
let payload_json = serde_json::json!({
"sub": "user123",
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
},
});
let payload = engine.encode(payload_json.to_string().as_bytes());
let sig = engine.encode(b"fake-signature");
format!("{header}.{payload}.{sig}")
}
/// Build a test `OpenAiCodexConfig` with a given session path.
pub(crate) fn test_codex_config(session_path: std::path::PathBuf) -> OpenAiCodexConfig {
OpenAiCodexConfig {
model: "gpt-5.3-codex".to_string(),
auth_endpoint: "https://auth.openai.com".to_string(),
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
client_id: "test_client_id".to_string(),
session_path,
token_refresh_margin_secs: 300,
}
}
+33
View File
@@ -9,6 +9,7 @@ use std::path::PathBuf;
use secrecy::SecretString;
use crate::bootstrap::ironclaw_base_dir;
use crate::llm::registry::ProviderProtocol;
use crate::llm::session::SessionConfig;
@@ -102,6 +103,36 @@ pub struct RegistryProviderConfig {
pub unsupported_params: Vec<String>,
}
/// Configuration for OpenAI Codex (ChatGPT subscription OAuth).
#[derive(Debug, Clone)]
pub struct OpenAiCodexConfig {
/// Model to use (default: "gpt-5.3-codex").
pub model: String,
/// OAuth authorization server (default: "https://auth.openai.com").
pub auth_endpoint: String,
/// Responses API base URL (default: "https://chatgpt.com/backend-api/codex").
pub api_base_url: String,
/// OAuth client ID (default: OpenAI's public Codex client).
pub client_id: String,
/// Path to session file (default: ~/.ironclaw/openai_codex_session.json).
pub session_path: PathBuf,
/// Seconds before expiry to proactively refresh (default: 300).
pub token_refresh_margin_secs: u64,
}
impl Default for OpenAiCodexConfig {
fn default() -> Self {
Self {
model: "gpt-5.3-codex".to_string(),
auth_endpoint: "https://auth.openai.com".to_string(),
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
session_path: ironclaw_base_dir().join("openai_codex_session.json"),
token_refresh_margin_secs: 300,
}
}
}
/// Configuration for AWS Bedrock (native Converse API).
#[derive(Debug, Clone)]
pub struct BedrockConfig {
@@ -134,6 +165,8 @@ pub struct LlmConfig {
pub provider: Option<RegistryProviderConfig>,
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
pub bedrock: Option<BedrockConfig>,
/// OpenAI Codex config (populated when backend=openai_codex).
pub openai_codex: Option<OpenAiCodexConfig>,
/// HTTP request timeout in seconds for LLM API calls.
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
/// need more time for prompt evaluation on consumer hardware.
+66 -2
View File
@@ -20,6 +20,8 @@ pub mod error;
pub mod failover;
mod nearai_chat;
pub mod oauth_helpers;
pub mod openai_codex_provider;
pub mod openai_codex_session;
mod provider;
mod reasoning;
pub mod recording;
@@ -29,6 +31,10 @@ pub mod retry;
mod rig_adapter;
pub mod session;
pub mod smart_routing;
mod token_refreshing;
#[cfg(test)]
mod codex_test_helpers;
pub mod image_models;
pub mod models;
@@ -37,12 +43,14 @@ pub mod vision_models;
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
pub use config::{
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
};
pub use error::LlmError;
pub use failover::{CooldownConfig, FailoverProvider};
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
pub use openai_codex_provider::OpenAiCodexProvider;
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
pub use provider::{
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
@@ -59,6 +67,7 @@ pub use retry::{RetryConfig, RetryProvider};
pub use rig_adapter::RigAdapter;
pub use session::{SessionConfig, SessionManager, create_session_manager};
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
pub use token_refreshing::TokenRefreshingProvider;
use std::sync::Arc;
@@ -97,6 +106,15 @@ pub async fn create_llm_provider(
}
}
if config.backend == "openai_codex" {
return Err(LlmError::RequestFailed {
provider: "openai_codex".to_string(),
reason:
"OpenAI Codex uses a dedicated factory path. Use build_provider_chain() instead of create_llm_provider()."
.to_string(),
});
}
let reg_config = config
.provider
.as_ref()
@@ -374,6 +392,47 @@ fn create_ollama_from_registry(
Ok(Arc::new(adapter))
}
/// Create an OpenAI Codex provider with OAuth authentication.
///
/// This is async because it needs to ensure authentication before
/// creating the provider (which requires a valid Bearer token).
///
/// Uses the Responses API (`chatgpt.com/backend-api/codex/responses`)
/// instead of the Chat Completions API, matching OpenClaw's approach.
async 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 session_mgr = Arc::new(OpenAiCodexSessionManager::new(codex.clone())?);
session_mgr.ensure_authenticated().await?;
let token = session_mgr.get_access_token().await?;
let provider = Arc::new(OpenAiCodexProvider::new(
&codex.model,
&codex.api_base_url,
token.expose_secret(),
config.request_timeout_secs,
)?);
tracing::info!(
"Using OpenAI Codex (Responses API, model: {}, base: {})",
codex.model,
codex.api_base_url,
);
Ok(Arc::new(TokenRefreshingProvider::new(
provider,
session_mgr,
)))
}
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
///
/// Resolution order:
@@ -460,7 +519,11 @@ pub async fn build_provider_chain(
),
LlmError,
> {
let llm = create_llm_provider(config, session.clone()).await?;
let llm: Arc<dyn LlmProvider> = if config.backend == "openai_codex" {
create_openai_codex_provider(config).await?
} else {
create_llm_provider(config, session.clone()).await?
};
tracing::debug!("LLM provider initialized: {}", llm.model_name());
// 1. Retry
@@ -632,6 +695,7 @@ mod tests {
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: true,
openai_codex: None,
}
}
+1
View File
@@ -347,5 +347,6 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
openai_codex: None,
}
}
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
//! 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);
}
}
+1 -1
View File
@@ -132,7 +132,7 @@ fn round_f32_to_f64(val: f32) -> f64 {
///
/// This is applied as a clone-and-transform at the provider boundary so the
/// original tool definitions remain unchanged for other providers.
fn normalize_schema_strict(schema: &JsonValue) -> JsonValue {
pub(crate) fn normalize_schema_strict(schema: &JsonValue) -> JsonValue {
let mut schema = schema.clone();
normalize_schema_recursive(&mut schema);
schema
+191
View File
@@ -0,0 +1,191 @@
//! Token-refreshing LlmProvider decorator for OpenAI Codex.
//!
//! Wraps an `OpenAiCodexProvider` and:
//! - Pre-emptively refreshes the OAuth access token before each call if near expiry
//! - Updates the inner provider's token after refresh (no client rebuild needed)
//! - Retries once on `AuthFailed` / `SessionExpired` after refreshing
//! - Overrides `cost_per_token()` to return (0, 0) since billing is through subscription
use std::sync::Arc;
use async_trait::async_trait;
use rust_decimal::Decimal;
use secrecy::ExposeSecret;
use crate::error::LlmError;
use crate::llm::openai_codex_provider::OpenAiCodexProvider;
use crate::llm::openai_codex_session::OpenAiCodexSessionManager;
use crate::llm::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
ToolCompletionResponse,
};
/// Decorator that refreshes OAuth tokens before API calls and reports zero cost.
///
/// The inner `OpenAiCodexProvider` manages its own token state, so after a
/// refresh we just call `update_token()` -- no client rebuild is needed.
pub struct TokenRefreshingProvider {
inner: Arc<OpenAiCodexProvider>,
session: Arc<OpenAiCodexSessionManager>,
}
impl TokenRefreshingProvider {
pub fn new(inner: Arc<OpenAiCodexProvider>, session: Arc<OpenAiCodexSessionManager>) -> Self {
Self { inner, session }
}
/// Push a fresh token from the session manager into the inner provider.
async fn update_inner_token(&self) -> Result<(), LlmError> {
let token = self.session.get_access_token().await?;
self.inner.update_token(token.expose_secret()).await?;
tracing::debug!("Updated inner provider token after refresh");
Ok(())
}
/// Best-effort pre-emptive token refresh before an API call.
///
/// If refresh fails (e.g., no refresh token), we log and continue so the
/// actual request still fires and the retry-on-auth-failure path can kick in.
async fn ensure_fresh_token(&self) {
if self.session.needs_refresh().await {
match self.session.refresh_tokens().await {
Ok(()) => {
if let Err(e) = self.update_inner_token().await {
tracing::warn!(
"Pre-emptive token update failed: {e}, will retry on auth failure"
);
}
}
Err(e) => {
tracing::warn!(
"Pre-emptive token refresh failed: {e}, will retry on auth failure"
);
}
}
}
}
}
#[async_trait]
impl LlmProvider for TokenRefreshingProvider {
fn model_name(&self) -> &str {
self.inner.model_name()
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
(Decimal::ZERO, Decimal::ZERO)
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.ensure_fresh_token().await;
match self.inner.complete(request.clone()).await {
Err(LlmError::AuthFailed { .. } | LlmError::SessionExpired { .. }) => {
tracing::info!("Auth failure during complete(), refreshing and retrying once");
self.session.handle_auth_failure().await?;
self.update_inner_token().await?;
self.inner.complete(request).await
}
other => other,
}
}
async fn complete_with_tools(
&self,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
self.ensure_fresh_token().await;
match self.inner.complete_with_tools(request.clone()).await {
Err(LlmError::AuthFailed { .. } | LlmError::SessionExpired { .. }) => {
tracing::info!(
"Auth failure during complete_with_tools(), refreshing and retrying once"
);
self.session.handle_auth_failure().await?;
self.update_inner_token().await?;
self.inner.complete_with_tools(request).await
}
other => other,
}
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
self.ensure_fresh_token().await;
self.inner.list_models().await
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
self.ensure_fresh_token().await;
self.inner.model_metadata().await
}
fn active_model_name(&self) -> String {
self.inner.model_name().to_string()
}
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
self.inner.effective_model_name(requested_model)
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
self.inner.set_model(model)
}
fn calculate_cost(&self, _input_tokens: u32, _output_tokens: u32) -> Decimal {
Decimal::ZERO
}
fn cache_write_multiplier(&self) -> Decimal {
self.inner.cache_write_multiplier()
}
fn cache_read_discount(&self) -> Decimal {
self.inner.cache_read_discount()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::codex_test_helpers::{make_test_jwt, test_codex_config};
use crate::llm::openai_codex_session::OpenAiCodexSessionManager;
use tempfile::tempdir;
fn make_provider_and_session() -> (TokenRefreshingProvider, tempfile::TempDir) {
let dir = tempdir().unwrap();
let config = test_codex_config(dir.path().join("session.json"));
let jwt = make_test_jwt("acct_test");
let inner = Arc::new(
OpenAiCodexProvider::new(&config.model, &config.api_base_url, &jwt, 300)
.expect("provider creation should succeed"),
);
let session = Arc::new(OpenAiCodexSessionManager::new(config).unwrap());
(TokenRefreshingProvider::new(inner, session), dir)
}
#[test]
fn test_model_name_delegates() {
let (provider, _dir) = make_provider_and_session();
assert_eq!(provider.model_name(), "gpt-5.3-codex");
}
#[test]
fn test_cost_per_token_zero() {
let (provider, _dir) = make_provider_and_session();
let (input, output) = provider.cost_per_token();
assert_eq!(input, Decimal::ZERO);
assert_eq!(output, Decimal::ZERO);
}
#[test]
fn test_calculate_cost_zero() {
let (provider, _dir) = make_provider_and_session();
assert_eq!(provider.calculate_cost(1000, 500), Decimal::ZERO);
}
#[test]
fn test_active_model_name_delegates() {
let (provider, _dir) = make_provider_and_session();
assert_eq!(provider.active_model_name(), "gpt-5.3-codex");
}
}
+41
View File
@@ -139,6 +139,47 @@ async fn async_main() -> anyhow::Result<()> {
)
.await;
}
Some(Command::Login { openai_codex }) => {
init_cli_tracing();
if *openai_codex {
// Resolve codex config so OPENAI_CODEX_* env overrides are
// honoured even when LLM_BACKEND isn't set to openai_codex.
let codex_config = {
let config = Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
config.llm.openai_codex.unwrap_or_else(|| {
use ironclaw::llm::OpenAiCodexConfig;
let mut cfg = OpenAiCodexConfig::default();
if let Ok(v) = std::env::var("OPENAI_CODEX_AUTH_URL") {
cfg.auth_endpoint = v;
}
if let Ok(v) = std::env::var("OPENAI_CODEX_API_URL") {
cfg.api_base_url = v;
}
if let Ok(v) = std::env::var("OPENAI_CODEX_CLIENT_ID") {
cfg.client_id = v;
}
if let Ok(v) = std::env::var("OPENAI_CODEX_SESSION_PATH") {
cfg.session_path = std::path::PathBuf::from(v);
}
cfg
})
};
let mgr = ironclaw::llm::OpenAiCodexSessionManager::new(codex_config)
.map_err(|e| anyhow::anyhow!("{}", e))?;
mgr.device_code_login()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
println!(
"OpenAI Codex authentication complete. Set LLM_BACKEND=openai_codex to use it."
);
} else {
println!("Specify a provider to authenticate with:");
println!(" ironclaw login --openai-codex (ChatGPT subscription)");
}
return Ok(());
}
Some(Command::Onboard {
skip_auth,
channels_only,
+41 -4
View File
@@ -3,7 +3,7 @@
//! The wizard guides users through:
//! 1. Database connection
//! 2. Security (secrets master key)
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, Ollama, OpenAI-compatible)
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, OpenAI Codex, Ollama, OpenAI-compatible)
//! 4. Model selection
//! 5. Embeddings
//! 6. Channel configuration
@@ -1083,8 +1083,10 @@ impl SetupWizard {
print_info(&format!("Current provider: {}", display));
println!();
let is_known =
current == "nearai" || current == "bedrock" || registry.is_known(&current);
let is_known = current == "nearai"
|| current == "bedrock"
|| current == "openai_codex"
|| registry.is_known(&current);
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
if current == "bedrock" {
@@ -1093,6 +1095,10 @@ impl SetupWizard {
print_info("Keeping existing AWS Bedrock configuration.");
return Ok(());
}
if current == "openai_codex" {
print_info("Keeping existing OpenAI Codex configuration.");
return Ok(());
}
return self.run_provider_setup(&current, &registry).await;
}
@@ -1107,7 +1113,7 @@ impl SetupWizard {
print_info("Select your inference provider:");
println!();
// Build menu: NearAI first, then all registry providers with setup hints, then Bedrock
// Build menu: NearAI first, then OpenAI Codex, then registry providers, then Bedrock
let selectable = registry.selectable();
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
let mut provider_ids: Vec<String> = Vec::with_capacity(2 + selectable.len());
@@ -1115,6 +1121,9 @@ impl SetupWizard {
options.push("NEAR AI - multi-model access via NEAR account".to_string());
provider_ids.push("nearai".to_string());
options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string());
provider_ids.push("openai_codex".to_string());
for def in &selectable {
let label = format!(
"{:<17}- {}",
@@ -1158,6 +1167,10 @@ impl SetupWizard {
return self.setup_nearai().await;
}
if provider_id == "openai_codex" {
return self.setup_openai_codex().await;
}
let def = registry
.find(provider_id)
.ok_or_else(|| SetupError::Config(format!("Unknown provider: {}", provider_id)))?;
@@ -1490,6 +1503,29 @@ impl SetupWizard {
Ok(())
}
/// OpenAI Codex (ChatGPT subscription) setup: device code OAuth flow.
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;
}
use crate::config::OpenAiCodexConfig;
use crate::llm::OpenAiCodexSessionManager;
let config = OpenAiCodexConfig::default();
let mgr = OpenAiCodexSessionManager::new(config).map_err(|e| {
SetupError::Config(format!("OpenAI Codex session manager init failed: {}", e))
})?;
mgr.device_code_login().await.map_err(|e| {
SetupError::Config(format!("OpenAI Codex authentication failed: {}", e))
})?;
print_success("OpenAI Codex configured (ChatGPT subscription)");
Ok(())
}
/// Generic Ollama-style setup: just needs a base URL, no API key.
fn setup_ollama_generic(
&mut self,
@@ -2963,6 +2999,7 @@ impl SetupWizard {
"ollama" => "Ollama",
"openai_compatible" => "OpenAI-compatible",
"bedrock" => "AWS Bedrock",
"openai_codex" => "OpenAI Codex",
other => other,
};
println!(" Provider: {}", display);