Skip to content
Merged
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 @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
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 @@ -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:
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
201 changes: 198 additions & 3 deletions src/config/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ impl LlmConfig {
},
provider: None,
bedrock: None,
openai_codex: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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).
Expand All @@ -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 {
Expand All @@ -198,6 +237,7 @@ impl LlmConfig {
nearai,
provider,
bedrock,
openai_codex,
request_timeout_secs,
cheap_model,
smart_routing_cascade,
Expand Down Expand Up @@ -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");
}
}
}
4 changes: 2 additions & 2 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/llm/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading