mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(llm): Add OpenAI Codex (ChatGPT subscription) as LLM provider (#1461)
* feat(llm): add OpenAI Codex backend config and OAuth session manager Add OpenAiCodex as a new LLM backend variant with config for auth endpoint, API base URL, client ID, and session persistence path. The session manager implements OpenAI's device code auth flow (headless-friendly, no browser required on the server) with automatic token refresh, following the same persistence pattern as the existing NEAR AI session manager. Closes #742 Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): add Responses API client and token-refreshing decorator Native Responses API client for chatgpt.com/backend-api/codex/responses, the endpoint that works with ChatGPT subscription tokens. Handles SSE streaming, text completions, and tool call round-trips. Token-refreshing decorator wraps the provider to pre-emptively refresh OAuth tokens before API calls and retry once on auth failures. Reports zero cost since billing is through subscription. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): wire OpenAI Codex into provider factory, CLI, and setup wizard Connect the new provider to the LLM factory, add openai_codex to the CLI --backend flag, and add it as an option in the onboarding wizard. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): address PR #744 review feedback (20 items) Review fixes for the OpenAI Codex provider PR: - Remove dead `generate_pkce()` code (device flow gets PKCE from server) - Fix `refresh_tokens()` to use `.form()` instead of `.json()` per OAuth spec - Inline codex dispatch into `build_provider_chain()` (single async function, no separate `assemble_provider_chain()` helper — matches main's pattern) - Remove Clone from `OpenAiCodexSession`, restrict fields to `pub(crate)` - Propagate HTTP client builder error instead of silent fallback - Redact device code response body from debug log - Change `set_model()` in TokenRefreshingProvider to delegate to inner - Replace hardcoded `/tmp/` test path with `tempfile::tempdir()` - Accept `request_timeout_secs` from config instead of hardcoded 300s - Parse `Retry-After` header on 429 responses (matches nearai_chat.rs pattern) - Reuse `normalize_schema_strict()` for Codex tool definitions - Add warning log for dropped image attachments - Add doc comments on `list_models()` and `include` field - Add `OPENAI_CODEX_API_URL` to `.env.example` - Fix codex error message in `create_llm_provider()` for clarity - Revert unrelated `.worktrees` addition to `.gitignore` - Update `src/llm/CLAUDE.md` with Codex provider docs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback and harden OpenAI Codex provider (takeover #744) Security: - Add SSRF validation (validate_base_url) on OPENAI_CODEX_AUTH_URL and OPENAI_CODEX_API_URL, matching the pattern used by all other base URL configs (regression test for #1103 included) Correctness: - Add missing cache_write_multiplier() and cache_read_discount() trait delegation in TokenRefreshingProvider - Cap device-code polling backoff at 60s to prevent unbounded interval growth on repeated 429 responses - Default expires_in to 3600s when server returns 0, preventing immediately-expired sessions - Fix pre-existing SseEvent::JobResult missing fallback_deliverable field in job_monitor.rs tests Cleanup: - Extract duplicated make_test_jwt() and test_codex_config() into shared codex_test_helpers module Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback on OpenAI Codex provider (#1461) - Login command now resolves OPENAI_CODEX_* env overrides even when LLM_BACKEND isn't set to openai_codex (Copilot review) - Setup wizard "Keep current provider?" for codex no longer re-triggers device code login — mirrors Bedrock's keep-and-return pattern (Copilot) - Revert provider init log from info back to debug (Copilot) - Add warning log when token expires_in=0, before defaulting to 3600s (Gemini review) Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Sanjeev Suresh <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Sanjeev Suresh
Claude Opus 4.6
parent
cba1bc3799
commit
3da9810e87
+8
-1
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
|
|||||||
|
|
||||||
# LLM Provider
|
# LLM Provider
|
||||||
# LLM_BACKEND=nearai # default
|
# 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)
|
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
||||||
|
|
||||||
# === Anthropic Direct ===
|
# === Anthropic Direct ===
|
||||||
@@ -92,6 +92,13 @@ NEARAI_AUTH_URL=https://private.near.ai
|
|||||||
# long = 1-hour TTL, 2.0× (200%) write surcharge
|
# long = 1-hour TTL, 2.0× (200%) write surcharge
|
||||||
# ANTHROPIC_CACHE_RETENTION=short
|
# 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
|
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||||
|
|
||||||
# Channel Configuration
|
# Channel Configuration
|
||||||
|
|||||||
@@ -696,6 +696,7 @@ impl AppBuilder {
|
|||||||
// fail early with a clear error instead of a confusing runtime failure.
|
// fail early with a clear error instead of a confusing runtime failure.
|
||||||
if self.config.llm.backend != "nearai"
|
if self.config.llm.backend != "nearai"
|
||||||
&& self.config.llm.backend != "bedrock"
|
&& self.config.llm.backend != "bedrock"
|
||||||
|
&& self.config.llm.backend != "openai_codex"
|
||||||
&& self.config.llm.provider.is_none()
|
&& self.config.llm.provider.is_none()
|
||||||
{
|
{
|
||||||
let backend = &self.config.llm.backend;
|
let backend = &self.config.llm.backend;
|
||||||
|
|||||||
@@ -239,6 +239,17 @@ pub enum Command {
|
|||||||
)]
|
)]
|
||||||
Import(ImportCommand),
|
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).
|
/// Run as a sandboxed worker inside a Docker container (internal use).
|
||||||
/// This is invoked automatically by the orchestrator, not by users directly.
|
/// This is invoked automatically by the orchestrator, not by users directly.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Commands:
|
|||||||
status Show system status
|
status Show system status
|
||||||
completion Generate completions
|
completion Generate completions
|
||||||
import Import from other AI systems
|
import Import from other AI systems
|
||||||
|
login Authenticate with a provider
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Commands:
|
|||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
completion Generate completions
|
completion Generate completions
|
||||||
|
login Authenticate with a provider
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ Commands:
|
|||||||
status Show system status
|
status Show system status
|
||||||
completion Generate completions
|
completion Generate completions
|
||||||
import Import from other AI systems
|
import Import from other AI systems
|
||||||
|
login Authenticate with a provider
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Commands:
|
|||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
completion Generate completions
|
completion Generate completions
|
||||||
|
login Authenticate with a provider
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|||||||
+198
-3
@@ -37,6 +37,7 @@ impl LlmConfig {
|
|||||||
},
|
},
|
||||||
provider: None,
|
provider: None,
|
||||||
bedrock: None,
|
bedrock: None,
|
||||||
|
openai_codex: None,
|
||||||
request_timeout_secs: 120,
|
request_timeout_secs: 120,
|
||||||
cheap_model: None,
|
cheap_model: None,
|
||||||
smart_routing_cascade: false,
|
smart_routing_cascade: false,
|
||||||
@@ -72,8 +73,12 @@ impl LlmConfig {
|
|||||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||||
let is_bedrock =
|
let is_bedrock =
|
||||||
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
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!(
|
tracing::warn!(
|
||||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||||
backend
|
backend
|
||||||
@@ -126,8 +131,8 @@ impl LlmConfig {
|
|||||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve registry provider config (for non-NearAI, non-Bedrock backends)
|
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
|
||||||
let provider = if is_nearai || is_bedrock {
|
let provider = if is_nearai || is_bedrock || is_openai_codex {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(Self::resolve_registry_provider(
|
Some(Self::resolve_registry_provider(
|
||||||
@@ -174,6 +179,38 @@ impl LlmConfig {
|
|||||||
None
|
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)?;
|
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
||||||
|
|
||||||
// Generic cheap model (works with any backend).
|
// Generic cheap model (works with any backend).
|
||||||
@@ -189,6 +226,8 @@ impl LlmConfig {
|
|||||||
"nearai".to_string()
|
"nearai".to_string()
|
||||||
} else if is_bedrock {
|
} else if is_bedrock {
|
||||||
"bedrock".to_string()
|
"bedrock".to_string()
|
||||||
|
} else if is_openai_codex {
|
||||||
|
"openai_codex".to_string()
|
||||||
} else if let Some(ref p) = provider {
|
} else if let Some(ref p) = provider {
|
||||||
p.provider_id.clone()
|
p.provider_id.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -198,6 +237,7 @@ impl LlmConfig {
|
|||||||
nearai,
|
nearai,
|
||||||
provider,
|
provider,
|
||||||
bedrock,
|
bedrock,
|
||||||
|
openai_codex,
|
||||||
request_timeout_secs,
|
request_timeout_secs,
|
||||||
cheap_model,
|
cheap_model,
|
||||||
smart_routing_cascade,
|
smart_routing_cascade,
|
||||||
@@ -1069,4 +1109,159 @@ mod tests {
|
|||||||
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
|
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::tunnel::TunnelConfig;
|
||||||
pub use self::wasm::WasmConfig;
|
pub use self::wasm::WasmConfig;
|
||||||
pub use crate::llm::config::{
|
pub use crate::llm::config::{
|
||||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
||||||
RegistryProviderConfig,
|
RegistryProviderConfig,
|
||||||
};
|
};
|
||||||
pub use crate::llm::session::SessionConfig;
|
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()`,
|
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||||
/// so explicit env vars always win.
|
/// 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.
|
/// credentials files) which don't require the secrets DB.
|
||||||
pub async fn inject_llm_keys_from_secrets(
|
pub async fn inject_llm_keys_from_secrets(
|
||||||
secrets: &dyn crate::secrets::SecretsStore,
|
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) |
|
| `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_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`) |
|
| `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` |
|
| `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 |
|
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
|
||||||
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
|
| `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` |
|
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
|
||||||
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
||||||
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
|
| `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:
|
Codex auth reuse:
|
||||||
- Set `LLM_USE_CODEX_AUTH=true` to load credentials from `~/.codex/auth.json` (override with `CODEX_AUTH_PATH`).
|
- 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).
|
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
|
## 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
|
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 secrecy::SecretString;
|
||||||
|
|
||||||
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::llm::registry::ProviderProtocol;
|
use crate::llm::registry::ProviderProtocol;
|
||||||
use crate::llm::session::SessionConfig;
|
use crate::llm::session::SessionConfig;
|
||||||
|
|
||||||
@@ -102,6 +103,36 @@ pub struct RegistryProviderConfig {
|
|||||||
pub unsupported_params: Vec<String>,
|
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).
|
/// Configuration for AWS Bedrock (native Converse API).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BedrockConfig {
|
pub struct BedrockConfig {
|
||||||
@@ -134,6 +165,8 @@ pub struct LlmConfig {
|
|||||||
pub provider: Option<RegistryProviderConfig>,
|
pub provider: Option<RegistryProviderConfig>,
|
||||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||||
pub bedrock: Option<BedrockConfig>,
|
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.
|
/// HTTP request timeout in seconds for LLM API calls.
|
||||||
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
|
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
|
||||||
/// need more time for prompt evaluation on consumer hardware.
|
/// need more time for prompt evaluation on consumer hardware.
|
||||||
|
|||||||
+66
-2
@@ -20,6 +20,8 @@ pub mod error;
|
|||||||
pub mod failover;
|
pub mod failover;
|
||||||
mod nearai_chat;
|
mod nearai_chat;
|
||||||
pub mod oauth_helpers;
|
pub mod oauth_helpers;
|
||||||
|
pub mod openai_codex_provider;
|
||||||
|
pub mod openai_codex_session;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod reasoning;
|
mod reasoning;
|
||||||
pub mod recording;
|
pub mod recording;
|
||||||
@@ -29,6 +31,10 @@ pub mod retry;
|
|||||||
mod rig_adapter;
|
mod rig_adapter;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod smart_routing;
|
pub mod smart_routing;
|
||||||
|
mod token_refreshing;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod codex_test_helpers;
|
||||||
|
|
||||||
pub mod image_models;
|
pub mod image_models;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
@@ -37,12 +43,14 @@ pub mod vision_models;
|
|||||||
|
|
||||||
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
||||||
RegistryProviderConfig,
|
RegistryProviderConfig,
|
||||||
};
|
};
|
||||||
pub use error::LlmError;
|
pub use error::LlmError;
|
||||||
pub use failover::{CooldownConfig, FailoverProvider};
|
pub use failover::{CooldownConfig, FailoverProvider};
|
||||||
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
|
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::{
|
pub use provider::{
|
||||||
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
||||||
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||||
@@ -59,6 +67,7 @@ pub use retry::{RetryConfig, RetryProvider};
|
|||||||
pub use rig_adapter::RigAdapter;
|
pub use rig_adapter::RigAdapter;
|
||||||
pub use session::{SessionConfig, SessionManager, create_session_manager};
|
pub use session::{SessionConfig, SessionManager, create_session_manager};
|
||||||
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
|
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
|
||||||
|
pub use token_refreshing::TokenRefreshingProvider;
|
||||||
|
|
||||||
use std::sync::Arc;
|
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
|
let reg_config = config
|
||||||
.provider
|
.provider
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -374,6 +392,47 @@ fn create_ollama_from_registry(
|
|||||||
Ok(Arc::new(adapter))
|
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).
|
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||||
///
|
///
|
||||||
/// Resolution order:
|
/// Resolution order:
|
||||||
@@ -460,7 +519,11 @@ pub async fn build_provider_chain(
|
|||||||
),
|
),
|
||||||
LlmError,
|
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());
|
tracing::debug!("LLM provider initialized: {}", llm.model_name());
|
||||||
|
|
||||||
// 1. Retry
|
// 1. Retry
|
||||||
@@ -632,6 +695,7 @@ mod tests {
|
|||||||
request_timeout_secs: 120,
|
request_timeout_secs: 120,
|
||||||
cheap_model: None,
|
cheap_model: None,
|
||||||
smart_routing_cascade: true,
|
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,
|
request_timeout_secs: 120,
|
||||||
cheap_model: None,
|
cheap_model: None,
|
||||||
smart_routing_cascade: false,
|
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
|
/// This is applied as a clone-and-transform at the provider boundary so the
|
||||||
/// original tool definitions remain unchanged for other providers.
|
/// 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();
|
let mut schema = schema.clone();
|
||||||
normalize_schema_recursive(&mut schema);
|
normalize_schema_recursive(&mut schema);
|
||||||
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;
|
.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 {
|
Some(Command::Onboard {
|
||||||
skip_auth,
|
skip_auth,
|
||||||
channels_only,
|
channels_only,
|
||||||
|
|||||||
+41
-4
@@ -3,7 +3,7 @@
|
|||||||
//! The wizard guides users through:
|
//! The wizard guides users through:
|
||||||
//! 1. Database connection
|
//! 1. Database connection
|
||||||
//! 2. Security (secrets master key)
|
//! 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
|
//! 4. Model selection
|
||||||
//! 5. Embeddings
|
//! 5. Embeddings
|
||||||
//! 6. Channel configuration
|
//! 6. Channel configuration
|
||||||
@@ -1083,8 +1083,10 @@ impl SetupWizard {
|
|||||||
print_info(&format!("Current provider: {}", display));
|
print_info(&format!("Current provider: {}", display));
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let is_known =
|
let is_known = current == "nearai"
|
||||||
current == "nearai" || current == "bedrock" || registry.is_known(¤t);
|
|| current == "bedrock"
|
||||||
|
|| current == "openai_codex"
|
||||||
|
|| registry.is_known(¤t);
|
||||||
|
|
||||||
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
||||||
if current == "bedrock" {
|
if current == "bedrock" {
|
||||||
@@ -1093,6 +1095,10 @@ impl SetupWizard {
|
|||||||
print_info("Keeping existing AWS Bedrock configuration.");
|
print_info("Keeping existing AWS Bedrock configuration.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if current == "openai_codex" {
|
||||||
|
print_info("Keeping existing OpenAI Codex configuration.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
return self.run_provider_setup(¤t, ®istry).await;
|
return self.run_provider_setup(¤t, ®istry).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,7 +1113,7 @@ impl SetupWizard {
|
|||||||
print_info("Select your inference provider:");
|
print_info("Select your inference provider:");
|
||||||
println!();
|
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 selectable = registry.selectable();
|
||||||
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
||||||
let mut provider_ids: 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());
|
options.push("NEAR AI - multi-model access via NEAR account".to_string());
|
||||||
provider_ids.push("nearai".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 {
|
for def in &selectable {
|
||||||
let label = format!(
|
let label = format!(
|
||||||
"{:<17}- {}",
|
"{:<17}- {}",
|
||||||
@@ -1158,6 +1167,10 @@ impl SetupWizard {
|
|||||||
return self.setup_nearai().await;
|
return self.setup_nearai().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if provider_id == "openai_codex" {
|
||||||
|
return self.setup_openai_codex().await;
|
||||||
|
}
|
||||||
|
|
||||||
let def = registry
|
let def = registry
|
||||||
.find(provider_id)
|
.find(provider_id)
|
||||||
.ok_or_else(|| SetupError::Config(format!("Unknown provider: {}", provider_id)))?;
|
.ok_or_else(|| SetupError::Config(format!("Unknown provider: {}", provider_id)))?;
|
||||||
@@ -1490,6 +1503,29 @@ impl SetupWizard {
|
|||||||
Ok(())
|
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.
|
/// Generic Ollama-style setup: just needs a base URL, no API key.
|
||||||
fn setup_ollama_generic(
|
fn setup_ollama_generic(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -2963,6 +2999,7 @@ impl SetupWizard {
|
|||||||
"ollama" => "Ollama",
|
"ollama" => "Ollama",
|
||||||
"openai_compatible" => "OpenAI-compatible",
|
"openai_compatible" => "OpenAI-compatible",
|
||||||
"bedrock" => "AWS Bedrock",
|
"bedrock" => "AWS Bedrock",
|
||||||
|
"openai_codex" => "OpenAI Codex",
|
||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
println!(" Provider: {}", display);
|
println!(" Provider: {}", display);
|
||||||
|
|||||||
Reference in New Issue
Block a user