mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 07:30:11 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcae0852df |
+13
-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
|
||||||
|
|
||||||
# === NEAR AI (Chat Completions API) ===
|
# === NEAR AI (Chat Completions API) ===
|
||||||
# Two auth modes:
|
# Two auth modes:
|
||||||
@@ -57,6 +57,18 @@ NEARAI_AUTH_URL=https://private.near.ai
|
|||||||
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||||
# LLM_API_KEY=fw_...
|
# LLM_API_KEY=fw_...
|
||||||
|
|
||||||
|
# === OpenAI Codex (Responses API) ===
|
||||||
|
# Two auth modes:
|
||||||
|
# 1. API key: Standard OpenAI billing (api.openai.com/v1/responses)
|
||||||
|
# 2. Codex CLI OAuth: ChatGPT subscription billing (chatgpt.com)
|
||||||
|
# Reads token from ~/.codex/auth.json (or $CODEX_HOME/auth.json)
|
||||||
|
# OPENAI_CODEX_MODEL=gpt-5.3-codex
|
||||||
|
# LLM_BACKEND=openai_codex
|
||||||
|
# OPENAI_CODEX_API_KEY=sk-... # API key mode
|
||||||
|
# CODEX_AUTH_PATH=~/.codex/auth.json # OAuth mode (default path)
|
||||||
|
# OPENAI_CODEX_ACCOUNT_ID=... # Required for ChatGPT endpoint
|
||||||
|
# OPENAI_CODEX_BASE_URL=... # Override base URL
|
||||||
|
|
||||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||||
|
|
||||||
# Channel Configuration
|
# Channel Configuration
|
||||||
|
|||||||
+263
-1
@@ -25,6 +25,8 @@ pub enum LlmBackend {
|
|||||||
OpenAiCompatible,
|
OpenAiCompatible,
|
||||||
/// Tinfoil private inference
|
/// Tinfoil private inference
|
||||||
Tinfoil,
|
Tinfoil,
|
||||||
|
/// OpenAI Codex via Responses API (ChatGPT OAuth or API key)
|
||||||
|
OpenAiCodex,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::str::FromStr for LlmBackend {
|
impl std::str::FromStr for LlmBackend {
|
||||||
@@ -38,8 +40,9 @@ impl std::str::FromStr for LlmBackend {
|
|||||||
"ollama" => Ok(Self::Ollama),
|
"ollama" => Ok(Self::Ollama),
|
||||||
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
|
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
|
||||||
"tinfoil" => Ok(Self::Tinfoil),
|
"tinfoil" => Ok(Self::Tinfoil),
|
||||||
|
"openai_codex" | "codex" => Ok(Self::OpenAiCodex),
|
||||||
_ => Err(format!(
|
_ => Err(format!(
|
||||||
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil",
|
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil, openai_codex",
|
||||||
s
|
s
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,7 @@ impl std::fmt::Display for LlmBackend {
|
|||||||
Self::Ollama => write!(f, "ollama"),
|
Self::Ollama => write!(f, "ollama"),
|
||||||
Self::OpenAiCompatible => write!(f, "openai_compatible"),
|
Self::OpenAiCompatible => write!(f, "openai_compatible"),
|
||||||
Self::Tinfoil => write!(f, "tinfoil"),
|
Self::Tinfoil => write!(f, "tinfoil"),
|
||||||
|
Self::OpenAiCodex => write!(f, "openai_codex"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +106,29 @@ pub struct TinfoilConfig {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for OpenAI Codex via Responses API.
|
||||||
|
///
|
||||||
|
/// Supports two auth modes:
|
||||||
|
/// - **API key**: Standard OpenAI billing via `api.openai.com/v1/responses`
|
||||||
|
/// - **OAuth**: ChatGPT subscription billing via `chatgpt.com/backend-api/codex/responses`,
|
||||||
|
/// using tokens from the Codex CLI (`~/.codex/auth.json`)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpenAiCodexConfig {
|
||||||
|
/// Model name (default: "gpt-5.3-codex").
|
||||||
|
pub model: String,
|
||||||
|
/// Base URL. Defaults based on auth mode:
|
||||||
|
/// - API key: `https://api.openai.com/v1`
|
||||||
|
/// - OAuth: `https://chatgpt.com/backend-api/codex`
|
||||||
|
pub base_url: String,
|
||||||
|
/// API key for api.openai.com (standard billing).
|
||||||
|
pub api_key: Option<SecretString>,
|
||||||
|
/// Path to Codex CLI auth.json for OAuth tokens.
|
||||||
|
/// Default: `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`).
|
||||||
|
pub auth_path: PathBuf,
|
||||||
|
/// OpenAI account ID (required for ChatGPT endpoint).
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// LLM provider configuration.
|
/// LLM provider configuration.
|
||||||
///
|
///
|
||||||
/// NEAR AI remains the default backend. Users can switch to other providers
|
/// NEAR AI remains the default backend. Users can switch to other providers
|
||||||
@@ -122,6 +149,8 @@ pub struct LlmConfig {
|
|||||||
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
||||||
/// Tinfoil config (populated when backend=tinfoil)
|
/// Tinfoil config (populated when backend=tinfoil)
|
||||||
pub tinfoil: Option<TinfoilConfig>,
|
pub tinfoil: Option<TinfoilConfig>,
|
||||||
|
/// OpenAI Codex config (populated when backend=openai_codex)
|
||||||
|
pub openai_codex: Option<OpenAiCodexConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NEAR AI configuration.
|
/// NEAR AI configuration.
|
||||||
@@ -325,6 +354,32 @@ impl LlmConfig {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let openai_codex = if backend == LlmBackend::OpenAiCodex {
|
||||||
|
let api_key = optional_env("OPENAI_CODEX_API_KEY")?.map(SecretString::from);
|
||||||
|
let model =
|
||||||
|
optional_env("OPENAI_CODEX_MODEL")?.unwrap_or_else(|| "gpt-5.3-codex".to_string());
|
||||||
|
let auth_path = optional_env("CODEX_AUTH_PATH")?
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(default_codex_auth_path);
|
||||||
|
let account_id = optional_env("OPENAI_CODEX_ACCOUNT_ID")?;
|
||||||
|
let base_url = optional_env("OPENAI_CODEX_BASE_URL")?.unwrap_or_else(|| {
|
||||||
|
if api_key.is_some() {
|
||||||
|
"https://api.openai.com/v1".to_string()
|
||||||
|
} else {
|
||||||
|
"https://chatgpt.com/backend-api/codex".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(OpenAiCodexConfig {
|
||||||
|
model,
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
auth_path,
|
||||||
|
account_id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
backend,
|
backend,
|
||||||
nearai,
|
nearai,
|
||||||
@@ -333,6 +388,7 @@ impl LlmConfig {
|
|||||||
ollama,
|
ollama,
|
||||||
openai_compatible,
|
openai_compatible,
|
||||||
tinfoil,
|
tinfoil,
|
||||||
|
openai_codex,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,6 +427,49 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
|
|||||||
Ok(headers)
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the default Codex CLI auth.json path.
|
||||||
|
///
|
||||||
|
/// Respects `$CODEX_HOME` if set, otherwise defaults to `~/.codex/auth.json`.
|
||||||
|
fn default_codex_auth_path() -> PathBuf {
|
||||||
|
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||||
|
return PathBuf::from(codex_home).join("auth.json");
|
||||||
|
}
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".codex")
|
||||||
|
.join("auth.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract an OAuth access token from a Codex CLI `auth.json` file.
|
||||||
|
///
|
||||||
|
/// Tries fields in order: `tokens.access_token`, `token`, `api_key`, `access_token`.
|
||||||
|
/// Returns `None` on any failure (file not found, parse error, no matching field).
|
||||||
|
pub fn extract_codex_oauth_token(auth_path: &std::path::Path) -> Option<String> {
|
||||||
|
let content = std::fs::read_to_string(auth_path).ok()?;
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||||
|
|
||||||
|
// Try nested tokens.access_token first (Codex CLI format)
|
||||||
|
if let Some(token) = json
|
||||||
|
.get("tokens")
|
||||||
|
.and_then(|t| t.get("access_token"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
&& !token.is_empty()
|
||||||
|
{
|
||||||
|
return Some(token.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try top-level fields
|
||||||
|
for field in &["token", "api_key", "access_token"] {
|
||||||
|
if let Some(val) = json.get(field).and_then(|v| v.as_str())
|
||||||
|
&& !val.is_empty()
|
||||||
|
{
|
||||||
|
return Some(val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the default session file path (~/.ironclaw/session.json).
|
/// Get the default session file path (~/.ironclaw/session.json).
|
||||||
fn default_session_path() -> PathBuf {
|
fn default_session_path() -> PathBuf {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
@@ -508,4 +607,167 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear codex-related env vars for testing.
|
||||||
|
fn clear_codex_env() {
|
||||||
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("LLM_BACKEND");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_API_KEY");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_BASE_URL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_ACCOUNT_ID");
|
||||||
|
std::env::remove_var("CODEX_AUTH_PATH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_defaults_model_and_oauth_base_url() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_codex_env();
|
||||||
|
|
||||||
|
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-5.3-codex");
|
||||||
|
// No API key → OAuth mode → ChatGPT base URL
|
||||||
|
assert!(codex.api_key.is_none());
|
||||||
|
assert_eq!(codex.base_url, "https://chatgpt.com/backend-api/codex");
|
||||||
|
assert!(codex.auth_path.to_string_lossy().contains("auth.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_api_key_sets_openai_base_url() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_codex_env();
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("OPENAI_CODEX_API_KEY", "sk-test-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
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!(codex.api_key.is_some());
|
||||||
|
assert_eq!(codex.base_url, "https://api.openai.com/v1");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("OPENAI_CODEX_API_KEY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_env_vars_override_defaults() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_codex_env();
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("OPENAI_CODEX_MODEL", "gpt-5.1-codex");
|
||||||
|
std::env::set_var("OPENAI_CODEX_BASE_URL", "https://custom.example.com/v1");
|
||||||
|
std::env::set_var("OPENAI_CODEX_ACCOUNT_ID", "acct_123");
|
||||||
|
std::env::set_var("CODEX_AUTH_PATH", "/tmp/test-auth.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
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-5.1-codex");
|
||||||
|
assert_eq!(codex.base_url, "https://custom.example.com/v1");
|
||||||
|
assert_eq!(codex.account_id.as_deref(), Some("acct_123"));
|
||||||
|
assert_eq!(
|
||||||
|
codex.auth_path,
|
||||||
|
std::path::PathBuf::from("/tmp/test-auth.json")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_BASE_URL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_ACCOUNT_ID");
|
||||||
|
std::env::remove_var("CODEX_AUTH_PATH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_not_populated_for_other_backends() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_codex_env();
|
||||||
|
|
||||||
|
let settings = Settings {
|
||||||
|
llm_backend: Some("nearai".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
|
assert!(cfg.openai_codex.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_nested() {
|
||||||
|
let dir = std::env::temp_dir().join("ironclaw-test-codex");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("auth-nested.json");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"tokens":{"access_token":"oauth-tok-123","refresh_token":"rt_456"}}"#,
|
||||||
|
)
|
||||||
|
.expect("write test file");
|
||||||
|
|
||||||
|
let token = extract_codex_oauth_token(&path);
|
||||||
|
assert_eq!(token, Some("oauth-tok-123".to_string()));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_flat() {
|
||||||
|
let dir = std::env::temp_dir().join("ironclaw-test-codex");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("auth-flat.json");
|
||||||
|
std::fs::write(&path, r#"{"token":"flat-tok-789"}"#).expect("write test file");
|
||||||
|
|
||||||
|
let token = extract_codex_oauth_token(&path);
|
||||||
|
assert_eq!(token, Some("flat-tok-789".to_string()));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_missing_file() {
|
||||||
|
let path = std::path::Path::new("/tmp/ironclaw-nonexistent-auth.json");
|
||||||
|
assert!(extract_codex_oauth_token(path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_empty_fields() {
|
||||||
|
let dir = std::env::temp_dir().join("ironclaw-test-codex");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("auth-empty.json");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"tokens":{"access_token":""},"token":"","api_key":""}"#,
|
||||||
|
)
|
||||||
|
.expect("write test file");
|
||||||
|
|
||||||
|
let token = extract_codex_oauth_token(&path);
|
||||||
|
assert!(token.is_none());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -37,8 +37,8 @@ pub use self::embeddings::EmbeddingsConfig;
|
|||||||
pub use self::heartbeat::HeartbeatConfig;
|
pub use self::heartbeat::HeartbeatConfig;
|
||||||
pub use self::hygiene::HygieneConfig;
|
pub use self::hygiene::HygieneConfig;
|
||||||
pub use self::llm::{
|
pub use self::llm::{
|
||||||
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
|
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig, OpenAiCodexConfig,
|
||||||
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
|
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, extract_codex_oauth_token,
|
||||||
};
|
};
|
||||||
pub use self::routines::RoutineConfig;
|
pub use self::routines::RoutineConfig;
|
||||||
pub use self::safety::SafetyConfig;
|
pub use self::safety::SafetyConfig;
|
||||||
@@ -220,6 +220,7 @@ pub async fn inject_llm_keys_from_secrets(
|
|||||||
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
||||||
("llm_compatible_api_key", "LLM_API_KEY"),
|
("llm_compatible_api_key", "LLM_API_KEY"),
|
||||||
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
||||||
|
("llm_codex_api_key", "OPENAI_CODEX_API_KEY"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut injected = HashMap::new();
|
let mut injected = HashMap::new();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod circuit_breaker;
|
|||||||
pub mod costs;
|
pub mod costs;
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
mod nearai_chat;
|
mod nearai_chat;
|
||||||
|
pub mod openai_codex;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod reasoning;
|
mod reasoning;
|
||||||
pub mod response_cache;
|
pub mod response_cache;
|
||||||
@@ -43,6 +44,7 @@ use secrecy::ExposeSecret;
|
|||||||
|
|
||||||
use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
|
use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
|
||||||
use crate::error::LlmError;
|
use crate::error::LlmError;
|
||||||
|
use crate::llm::openai_codex::OpenAiCodexProvider;
|
||||||
|
|
||||||
/// Create an LLM provider based on configuration.
|
/// Create an LLM provider based on configuration.
|
||||||
///
|
///
|
||||||
@@ -60,6 +62,7 @@ pub fn create_llm_provider(
|
|||||||
LlmBackend::Ollama => create_ollama_provider(config),
|
LlmBackend::Ollama => create_ollama_provider(config),
|
||||||
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
|
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
|
||||||
LlmBackend::Tinfoil => create_tinfoil_provider(config),
|
LlmBackend::Tinfoil => create_tinfoil_provider(config),
|
||||||
|
LlmBackend::OpenAiCodex => create_openai_codex_provider(config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +268,28 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
|
|||||||
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 auth_mode = if codex.api_key.is_some() {
|
||||||
|
"API key"
|
||||||
|
} else {
|
||||||
|
"OAuth (Codex CLI)"
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
model = %codex.model,
|
||||||
|
base_url = %codex.base_url,
|
||||||
|
auth = auth_mode,
|
||||||
|
"Using OpenAI Codex (Responses API)"
|
||||||
|
);
|
||||||
|
Ok(Arc::new(OpenAiCodexProvider::new(codex.clone())?))
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||||
///
|
///
|
||||||
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
|
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
|
||||||
@@ -472,6 +497,7 @@ mod tests {
|
|||||||
ollama: None,
|
ollama: None,
|
||||||
openai_compatible: None,
|
openai_compatible: None,
|
||||||
tinfoil: None,
|
tinfoil: None,
|
||||||
|
openai_codex: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+108
-1
@@ -751,6 +751,7 @@ impl SetupWizard {
|
|||||||
"openai" => "OpenAI",
|
"openai" => "OpenAI",
|
||||||
"ollama" => "Ollama (local)",
|
"ollama" => "Ollama (local)",
|
||||||
"openai_compatible" => "OpenAI-compatible endpoint",
|
"openai_compatible" => "OpenAI-compatible endpoint",
|
||||||
|
"openai_codex" => "OpenAI Codex (Responses API)",
|
||||||
other => other,
|
other => other,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -759,7 +760,7 @@ impl SetupWizard {
|
|||||||
|
|
||||||
let is_known = matches!(
|
let is_known = matches!(
|
||||||
current.as_str(),
|
current.as_str(),
|
||||||
"nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible"
|
"nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" | "openai_codex"
|
||||||
);
|
);
|
||||||
|
|
||||||
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
||||||
@@ -773,6 +774,7 @@ impl SetupWizard {
|
|||||||
"openai" => return self.setup_openai().await,
|
"openai" => return self.setup_openai().await,
|
||||||
"ollama" => return self.setup_ollama(),
|
"ollama" => return self.setup_ollama(),
|
||||||
"openai_compatible" => return self.setup_openai_compatible().await,
|
"openai_compatible" => return self.setup_openai_compatible().await,
|
||||||
|
"openai_codex" => return self.setup_openai_codex().await,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(SetupError::Config(format!(
|
return Err(SetupError::Config(format!(
|
||||||
"Unhandled provider: {}",
|
"Unhandled provider: {}",
|
||||||
@@ -800,6 +802,7 @@ impl SetupWizard {
|
|||||||
"Ollama - local models, no API key needed",
|
"Ollama - local models, no API key needed",
|
||||||
"OpenRouter - 200+ models via single API key",
|
"OpenRouter - 200+ models via single API key",
|
||||||
"OpenAI-compatible - custom endpoint (vLLM, LiteLLM, etc.)",
|
"OpenAI-compatible - custom endpoint (vLLM, LiteLLM, etc.)",
|
||||||
|
"OpenAI Codex - Responses API (ChatGPT OAuth or API key)",
|
||||||
];
|
];
|
||||||
|
|
||||||
let choice = select_one("Provider:", options).map_err(SetupError::Io)?;
|
let choice = select_one("Provider:", options).map_err(SetupError::Io)?;
|
||||||
@@ -811,6 +814,7 @@ impl SetupWizard {
|
|||||||
3 => self.setup_ollama()?,
|
3 => self.setup_ollama()?,
|
||||||
4 => self.setup_openrouter().await?,
|
4 => self.setup_openrouter().await?,
|
||||||
5 => self.setup_openai_compatible().await?,
|
5 => self.setup_openai_compatible().await?,
|
||||||
|
6 => self.setup_openai_codex().await?,
|
||||||
_ => return Err(SetupError::Config("Invalid provider selection".to_string())),
|
_ => return Err(SetupError::Config("Invalid provider selection".to_string())),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,6 +1070,100 @@ impl SetupWizard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OpenAI Codex provider setup: API key or Codex CLI OAuth.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_options = &[
|
||||||
|
"API key - standard OpenAI billing (api.openai.com)",
|
||||||
|
"Codex CLI OAuth - ChatGPT subscription billing (~/.codex/auth.json)",
|
||||||
|
];
|
||||||
|
|
||||||
|
let auth_choice =
|
||||||
|
select_one("Authentication mode:", auth_options).map_err(SetupError::Io)?;
|
||||||
|
|
||||||
|
match auth_choice {
|
||||||
|
0 => {
|
||||||
|
// API key mode — delegate to shared helper
|
||||||
|
self.setup_api_key_provider(
|
||||||
|
"openai_codex",
|
||||||
|
"OPENAI_CODEX_API_KEY",
|
||||||
|
"llm_codex_api_key",
|
||||||
|
"OpenAI API key (for Codex)",
|
||||||
|
"https://platform.openai.com/api-keys",
|
||||||
|
Some("OpenAI Codex"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// OAuth mode — read from Codex CLI auth.json
|
||||||
|
let auth_path = std::env::var("CODEX_AUTH_PATH").unwrap_or_else(|_| {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join(".codex")
|
||||||
|
.join("auth.json")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let path = std::path::Path::new(&auth_path);
|
||||||
|
match crate::config::extract_codex_oauth_token(path) {
|
||||||
|
Some(token) => {
|
||||||
|
print_info(&format!(
|
||||||
|
"Found Codex OAuth token: {}",
|
||||||
|
mask_api_key(&token)
|
||||||
|
));
|
||||||
|
if !confirm("Use this token?", true).map_err(SetupError::Io)? {
|
||||||
|
return Err(SetupError::Cancelled);
|
||||||
|
}
|
||||||
|
print_success("OpenAI Codex configured (OAuth from Codex CLI)");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
print_error(&format!("No Codex OAuth token found at {}", path.display()));
|
||||||
|
print_info(
|
||||||
|
"Run `npx codex --full-setup` to authenticate, then retry setup.",
|
||||||
|
);
|
||||||
|
if confirm("Retry after authenticating?", true).map_err(SetupError::Io)? {
|
||||||
|
// Check again after user's action
|
||||||
|
match crate::config::extract_codex_oauth_token(path) {
|
||||||
|
Some(_) => {
|
||||||
|
print_success("OpenAI Codex configured (OAuth from Codex CLI)");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(SetupError::Auth(format!(
|
||||||
|
"Still no token found at {}. Run Codex CLI setup first.",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(SetupError::Cancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt for account ID (required for ChatGPT endpoint)
|
||||||
|
let account_id = optional_input(
|
||||||
|
"OpenAI account ID (for ChatGPT endpoint, optional)",
|
||||||
|
Some("leave blank if unknown"),
|
||||||
|
)
|
||||||
|
.map_err(SetupError::Io)?;
|
||||||
|
|
||||||
|
if let Some(ref id) = account_id
|
||||||
|
&& !id.is_empty()
|
||||||
|
{
|
||||||
|
print_info(&format!("Account ID: {}", id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(SetupError::Config("Invalid auth choice".to_string())),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Step 4: Model selection.
|
/// Step 4: Model selection.
|
||||||
///
|
///
|
||||||
/// Branches on the selected LLM backend and fetches models from the
|
/// Branches on the selected LLM backend and fetches models from the
|
||||||
@@ -1127,6 +1225,14 @@ impl SetupWizard {
|
|||||||
self.settings.selected_model = Some(model_id.clone());
|
self.settings.selected_model = Some(model_id.clone());
|
||||||
print_success(&format!("Selected {}", model_id));
|
print_success(&format!("Selected {}", model_id));
|
||||||
}
|
}
|
||||||
|
"openai_codex" => {
|
||||||
|
// OAuth tokens can't call /v1/models — use hardcoded list
|
||||||
|
let models: Vec<(String, String)> = crate::llm::openai_codex::CODEX_MODELS
|
||||||
|
.iter()
|
||||||
|
.map(|(id, desc)| (id.to_string(), desc.to_string()))
|
||||||
|
.collect();
|
||||||
|
self.select_from_model_list(&models)?;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// NEAR AI: use existing provider list_models()
|
// NEAR AI: use existing provider list_models()
|
||||||
let fetched = self.fetch_nearai_models().await;
|
let fetched = self.fetch_nearai_models().await;
|
||||||
@@ -1230,6 +1336,7 @@ impl SetupWizard {
|
|||||||
ollama: None,
|
ollama: None,
|
||||||
openai_compatible: None,
|
openai_compatible: None,
|
||||||
tinfoil: None,
|
tinfoil: None,
|
||||||
|
openai_codex: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match create_llm_provider(&config, session) {
|
match create_llm_provider(&config, session) {
|
||||||
|
|||||||
Reference in New Issue
Block a user