Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===
Expand Down Expand Up @@ -81,6 +81,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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with the implementation in src/config/llm.rs, it would be helpful to also document the OPENAI_CODEX_API_URL environment variable here. The code allows overriding the API base URL, but it's not mentioned in this example file, which could be confusing for users trying to configure a proxy.

# OPENAI_CODEX_AUTH_URL=https://auth.openai.com  # override (rare)
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # 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
Expand Down
5 changes: 4 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,10 @@ impl AppBuilder {
// Post-init validation: if a non-nearai backend was selected but
// credentials were never resolved (deferred resolution found no keys),
// fail early with a clear error instead of a confusing runtime failure.
if self.config.llm.backend != "nearai" && self.config.llm.provider.is_none() {
if self.config.llm.backend != "nearai"
&& self.config.llm.backend != "openai_codex"
&& self.config.llm.provider.is_none()
{
let backend = &self.config.llm.backend;
anyhow::bail!(
"LLM_BACKEND={backend} is configured but no credentials were found. \
Expand Down
11 changes: 11 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,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)]
Expand Down
1 change: 1 addition & 0 deletions src/cli/snapshots/ironclaw__cli__tests__help_output.snap
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Commands:
doctor Run diagnostics
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Commands:
doctor Run diagnostics
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:
Expand Down
140 changes: 137 additions & 3 deletions src/config/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ impl LlmConfig {
},
provider: None,
bedrock: None,
openai_codex: None,
request_timeout_secs: 120,
}
}
Expand Down Expand Up @@ -71,8 +72,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
Expand Down Expand Up @@ -119,8 +124,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(
Expand Down Expand Up @@ -167,13 +172,45 @@ 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());
let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
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)?;

Ok(Self {
backend: if is_nearai {
"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 {
Expand All @@ -183,6 +220,7 @@ impl LlmConfig {
nearai,
provider,
bedrock,
openai_codex,
request_timeout_secs,
})
}
Expand Down Expand Up @@ -990,4 +1028,100 @@ 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");
}
}
4 changes: 2 additions & 2 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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;
Expand Down Expand Up @@ -328,7 +328,7 @@ impl Config {
/// 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,
Expand Down
26 changes: 24 additions & 2 deletions src/llm/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon

| File | Role |
|------|------|
| `mod.rs` | Provider factory (`create_llm_provider`, `build_provider_chain`); `LlmBackend` enum |
| `mod.rs` | Provider factory (`create_llm_provider`, `build_provider_chain`) |
| `provider.rs` | `LlmProvider` trait, `ChatMessage`, `ToolCall`, `CompletionRequest`, `sanitize_tool_messages` |
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
| `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 |
Expand All @@ -34,6 +37,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` |

## AWS Bedrock Provider

Expand Down Expand Up @@ -138,9 +142,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
Expand Down
35 changes: 35 additions & 0 deletions src/llm/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
//! extracted into a standalone crate. Resolution logic (reading env vars,
//! settings) lives in `crate::config::llm`.

use std::path::PathBuf;

use secrecy::SecretString;

use crate::bootstrap::ironclaw_base_dir;
use crate::llm::registry::ProviderProtocol;
use crate::llm::session::SessionConfig;

Expand Down Expand Up @@ -93,6 +96,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 {
Expand Down Expand Up @@ -125,6 +158,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.
Expand Down
Loading
Loading