mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
+8
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¤t);
|
||||
let is_known = current == "nearai"
|
||||
|| current == "bedrock"
|
||||
|| current == "openai_codex"
|
||||
|| registry.is_known(¤t);
|
||||
|
||||
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(¤t, ®istry).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);
|
||||
|
||||
Reference in New Issue
Block a user