diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 62f826d9cb..e78add04c3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -528,6 +528,7 @@ dependencies = [ "libc", "mcp-types", "mime_guess", + "once_cell", "openssl-sys", "patch", "path-absolutize", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d989aeafee..29854d93cf 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -28,6 +28,7 @@ rand = "0.9" reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +once_cell = "1.19.0" thiserror = "2.0.12" tokio = { version = "1", features = [ "io-std", diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 79f99e8c12..50a5513cfe 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -26,10 +26,8 @@ use tracing::warn; use crate::error::CodexErr; use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; -use crate::flags::OPENAI_API_BASE; use crate::flags::OPENAI_REQUEST_MAX_RETRIES; use crate::flags::OPENAI_STREAM_IDLE_TIMEOUT_MS; -use crate::flags::get_api_key; use crate::models::ResponseItem; use crate::util::backoff; @@ -141,13 +139,22 @@ static DEFAULT_TOOLS: LazyLock> = LazyLock::new(|| { pub struct ModelClient { model: String, client: reqwest::Client, + provider_key: String, + provider: crate::model_provider_info::ModelProviderInfo, } impl ModelClient { - pub fn new(model: impl ToString) -> Self { - let model = model.to_string(); - let client = reqwest::Client::new(); - Self { model, client } + pub fn new( + model: impl ToString, + provider_key: impl ToString, + provider: crate::model_provider_info::ModelProviderInfo, + ) -> Self { + Self { + model: model.to_string(), + client: reqwest::Client::new(), + provider_key: provider_key.to_string(), + provider, + } } pub async fn stream(&mut self, prompt: &Prompt) -> Result { @@ -188,7 +195,9 @@ impl ModelClient { stream: true, }; - let url = format!("{}/v1/responses", *OPENAI_API_BASE); + let base_url = self.provider.api_base(&self.provider_key); + let base_url = base_url.trim_end_matches('/'); + let url = format!("{}/responses", base_url); debug!(url, "POST"); trace!("request payload: {}", serde_json::to_string(&payload)?); @@ -196,10 +205,14 @@ impl ModelClient { loop { attempt += 1; + let api_key = self + .provider + .api_key() + .ok_or_else(|| crate::error::CodexErr::EnvVar("API_KEY"))?; let res = self .client .post(&url) - .bearer_auth(get_api_key()?) + .bearer_auth(api_key) .header("OpenAI-Beta", "responses=experimental") .header(reqwest::header::ACCEPT, "text/event-stream") .json(&payload) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 36d4f119d7..e56410b5f2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -540,6 +540,7 @@ async fn submission_loop( sess.abort(); } Op::ConfigureSession { + provider, model, instructions, approval_policy, @@ -548,7 +549,7 @@ async fn submission_loop( notify, cwd, } => { - info!(model, "Configuring session"); + info!(model, provider, "Configuring session"); if !cwd.is_absolute() { let message = format!("cwd is not absolute: {cwd:?}"); error!(message); @@ -562,7 +563,25 @@ async fn submission_loop( return; } - let client = ModelClient::new(model.clone()); + // Load config to resolve provider information & MCP servers. + let config = match Config::load_with_overrides(ConfigOverrides::default()) { + Ok(cfg) => cfg, + Err(e) => { + error!("Failed to load config: {e:#}"); + Config::load_default_config_for_test() + } + }; + + let provider_map = crate::model_provider_info::provider_map(&config); + let provider_info = provider_map + .get(&provider.to_lowercase()) + .cloned() + .unwrap_or_else(|| { + crate::model_provider_info::default_providers()["openai"].clone() + }); + + let client = + ModelClient::new(model.clone(), provider.clone(), provider_info.clone()); // abort any current running session and clone its state let state = match sess.take() { diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs index b27cab7151..e8552ce70c 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -20,6 +20,7 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(CodexWrapper, Event, let codex = CodexWrapper::new(Codex::spawn(ctrl_c.clone())?); let init_id = codex .submit(Op::ConfigureSession { + provider: config.provider.clone(), model: config.model.clone(), instructions: config.instructions.clone(), approval_policy: config.approval_policy, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 68fec35ebf..62deaf19a1 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -19,6 +19,9 @@ pub struct Config { /// Optional override of model selection. pub model: String, + /// Selected provider ("openai", "gemini", …) + pub provider: String, + /// Approval policy for executing commands. pub approval_policy: AskForApproval, @@ -61,6 +64,9 @@ pub struct Config { /// Definition for MCP servers that Codex can reach out to for tool calls. pub mcp_servers: HashMap, + + /// Combined provider map (defaults merged with user-defined overrides). + pub providers: HashMap, } /// Base config deserialized from ~/.codex/config.toml. @@ -69,6 +75,9 @@ pub struct ConfigToml { /// Optional override of model selection. pub model: Option, + /// Selected provider + pub provider: Option, + /// Default approval policy for executing commands. pub approval_policy: Option, @@ -93,6 +102,11 @@ pub struct ConfigToml { /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] pub mcp_servers: HashMap, + + /// User-defined provider entries that extend/override the built-in list + /// (`codex-cli/src/utils/providers.ts`). + #[serde(default)] + pub providers: HashMap, } impl ConfigToml { @@ -152,6 +166,8 @@ pub struct ConfigOverrides { pub approval_policy: Option, pub sandbox_policy: Option, pub disable_response_storage: Option, + + pub provider: Option, } impl Config { @@ -176,6 +192,7 @@ impl Config { approval_policy, sandbox_policy, disable_response_storage, + provider, } = overrides; let sandbox_policy = match sandbox_policy { @@ -195,6 +212,9 @@ impl Config { Self { model: model.or(cfg.model).unwrap_or_else(default_model), + provider: provider + .or(cfg.provider) + .unwrap_or_else(|| "openai".to_string()), cwd: cwd.map_or_else( || { tracing::info!("cwd not set, using current dir"); @@ -222,6 +242,7 @@ impl Config { notify: cfg.notify, instructions, mcp_servers: cfg.mcp_servers, + providers: cfg.providers, } } diff --git a/codex-rs/core/src/flags.rs b/codex-rs/core/src/flags.rs index 4d0d4bbe47..156b3c371c 100644 --- a/codex-rs/core/src/flags.rs +++ b/codex-rs/core/src/flags.rs @@ -2,28 +2,26 @@ use std::time::Duration; use env_flags::env_flags; -use crate::error::CodexErr; -use crate::error::Result; - env_flags! { pub OPENAI_DEFAULT_MODEL: &str = "o3"; - pub OPENAI_API_BASE: &str = "https://api.openai.com"; + // Retained for backward compatibility (now includes /v1). + pub OPENAI_API_BASE: &str = "https://api.openai.com/v1"; + + // Fallback when the provider-specific key is not set. pub OPENAI_API_KEY: Option<&str> = None; + pub OPENAI_TIMEOUT_MS: Duration = Duration::from_millis(300_000), |value| { value.parse().map(Duration::from_millis) }; + pub OPENAI_REQUEST_MAX_RETRIES: u64 = 4; pub OPENAI_STREAM_MAX_RETRIES: u64 = 10; - // We generally don't want to disconnect; this updates the timeout to be five minutes - // which matches the upstream typescript codex impl. + // We generally don't want to disconnect; this matches the upstream TS CLI. pub OPENAI_STREAM_IDLE_TIMEOUT_MS: Duration = Duration::from_millis(300_000), |value| { value.parse().map(Duration::from_millis) }; + // Fixture path for offline tests (see client.rs). pub CODEX_RS_SSE_FIXTURE: Option<&str> = None; } - -pub fn get_api_key() -> Result<&'static str> { - OPENAI_API_KEY.ok_or_else(|| CodexErr::EnvVar("OPENAI_API_KEY")) -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 919d05f154..ad0a158917 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -18,6 +18,7 @@ pub mod linux; mod mcp_connection_manager; pub mod mcp_server_config; mod mcp_tool_call; +mod model_provider_info; mod models; pub mod protocol; mod safety; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs new file mode 100644 index 0000000000..93353f264f --- /dev/null +++ b/codex-rs/core/src/model_provider_info.rs @@ -0,0 +1,136 @@ +//! Registry of model providers supported by Codex. +//! +//! Providers can be defined in two places: +//! 1. Built-in defaults compiled into the binary so Codex works out-of-the-box. +//! 2. User-defined entries inside `~/.codex/config.toml` under the `providers` +//! key. These override or extend the defaults at runtime. +//! +//! The combined mapping is surfaced via [`provider_map()`] and used by helper +//! functions in [`crate::flags`] to resolve API keys and base URLs. + +use serde::Deserialize; +use std::collections::HashMap; + +/// Serializable representation of a provider definition. +/// +/// All fields are owned `String`s so that user-defined providers loaded from +/// disk can be stored alongside the built-ins without lifetime headaches. +#[derive(Debug, Clone, Deserialize)] +pub struct ModelProviderInfo { + /// Friendly display name (optional for built-ins). + #[serde(default)] + pub name: String, + /// Base URL for the provider’s OpenAI-compatible API. + pub base_url: String, + /// Environment variable that stores the user’s API key for this provider. + pub env_key: String, +} + +impl ModelProviderInfo { + /// Returns the API key for this provider if present in the environment. + pub fn api_key(&self) -> Option { + std::env::var(&self.env_key).ok() + } + + /// Determines the base URL for API requests, giving precedence to the + /// `{{PROVIDER}}_BASE_URL` environment variable when it is set. + pub fn api_base(&self, provider_key: &str) -> String { + let override_key = format!("{}_BASE_URL", provider_key.to_uppercase()); + if let Ok(val) = std::env::var(&override_key) { + if !val.is_empty() { + return val; + } + } + self.base_url.clone() + } +} + +/// Built-in default provider list – mirrors `codex-cli/src/utils/providers.ts`. +/// Built-in provider registry. Public so callers (e.g. flags.rs) can resolve +/// information without needing a full [`crate::config::Config`]. +pub fn default_providers() -> HashMap { + use ModelProviderInfo as P; + + [ + ( + "openai", + P { + name: "OpenAI".into(), + base_url: "https://api.openai.com/v1".into(), + env_key: "OPENAI_API_KEY".into(), + }, + ), + ( + "openrouter", + P { + name: "OpenRouter".into(), + base_url: "https://openrouter.ai/api/v1".into(), + env_key: "OPENROUTER_API_KEY".into(), + }, + ), + ( + "gemini", + P { + name: "Gemini".into(), + base_url: "https://generativelanguage.googleapis.com/v1beta/openai".into(), + env_key: "GEMINI_API_KEY".into(), + }, + ), + ( + "ollama", + P { + name: "Ollama".into(), + base_url: "http://localhost:11434/v1".into(), + env_key: "OLLAMA_API_KEY".into(), + }, + ), + ( + "mistral", + P { + name: "Mistral".into(), + base_url: "https://api.mistral.ai/v1".into(), + env_key: "MISTRAL_API_KEY".into(), + }, + ), + ( + "deepseek", + P { + name: "DeepSeek".into(), + base_url: "https://api.deepseek.com".into(), + env_key: "DEEPSEEK_API_KEY".into(), + }, + ), + ( + "xai", + P { + name: "xAI".into(), + base_url: "https://api.x.ai/v1".into(), + env_key: "XAI_API_KEY".into(), + }, + ), + ( + "groq", + P { + name: "Groq".into(), + base_url: "https://api.groq.com/openai/v1".into(), + env_key: "GROQ_API_KEY".into(), + }, + ), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect() +} + +/// Merge built-in defaults with user-defined overrides from the supplied +/// [`crate::config::Config`]. When the same provider key appears in both maps +/// the user-defined entry wins. +pub fn provider_map(cfg: &crate::config::Config) -> HashMap { + let mut map = default_providers(); + map.extend(cfg.providers.clone()); + + // Normalise keys to lower-case for case-insensitive look-ups. + map.into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect() +} diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 4796381dbf..04d848f9fd 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -20,6 +20,10 @@ pub struct Submission { pub op: Op, } +fn default_provider() -> String { + "openai".to_string() +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -27,6 +31,11 @@ pub struct Submission { pub enum Op { /// Configure the model session. ConfigureSession { + /// Provider identifier ("openai", "gemini", …). Defaults to + /// "openai" when omitted so that older clients continue to work. + #[serde(default = "default_provider")] + provider: String, + /// If not specified, server will use its default model. model: String, /// Model instructions diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index 55476ecf44..f0bc5bf148 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -61,6 +61,7 @@ async fn spawn_codex() -> Codex { .submit(Submission { id: "init".into(), op: Op::ConfigureSession { + provider: "openai".to_string(), model: config.model, instructions: None, approval_policy: config.approval_policy, diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index 5487b5e3f2..ec417f5484 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -96,6 +96,7 @@ async fn keeps_previous_response_id_between_tasks() { .submit(Submission { id: "init".into(), op: Op::ConfigureSession { + provider: "openai".to_string(), model: config.model, instructions: None, approval_policy: config.approval_policy, diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 608516a0de..353a4b6dbe 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -84,6 +84,7 @@ async fn retries_on_early_close() { .submit(Submission { id: "init".into(), op: Op::ConfigureSession { + provider: "openai".to_string(), model: config.model, instructions: None, approval_policy: config.approval_policy, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1bd5069eed..cb11ca6247 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -66,6 +66,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { None }, cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)), + provider: None, }; let config = Config::load_with_overrides(overrides)?; diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index d05ec1549e..89b19f726a 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -158,6 +158,7 @@ impl CodexToolCallParam { approval_policy: approval_policy.map(Into::into), sandbox_policy, disable_response_storage, + provider: None, }; let cfg = codex_core::config::Config::load_with_overrides(overrides)?; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 30169699c5..a7de9aae63 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -58,6 +58,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> { None }, cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)), + provider: None, }; #[allow(clippy::print_stderr)] match Config::load_with_overrides(overrides) {