diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9e5cd85065..db90d26420 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 3319ef1014..fc5a946bba 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" time = { version = "0.3", features = ["formatting", "macros"] } tokio = { version = "1", features = [ diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 79f99e8c12..d67b420b46 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -26,10 +26,9 @@ 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::model_provider_info::ModelProviderInfo; use crate::models::ResponseItem; use crate::util::backoff; @@ -141,13 +140,16 @@ static DEFAULT_TOOLS: LazyLock> = LazyLock::new(|| { pub struct ModelClient { model: String, client: reqwest::Client, + provider: 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: ModelProviderInfo) -> Self { + Self { + model: model.to_string(), + client: reqwest::Client::new(), + provider, + } } pub async fn stream(&mut self, prompt: &Prompt) -> Result { @@ -188,18 +190,28 @@ impl ModelClient { stream: true, }; - let url = format!("{}/v1/responses", *OPENAI_API_BASE); + let base_url = self.provider.api_base(&self.provider.base_url); + 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)?); + println!( + "request {url:?} payload: {:?}", + serde_json::to_string(&payload) + ); let mut attempt = 0; 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 b80e33a41e..563c6ca843 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -564,6 +564,7 @@ async fn submission_loop( sess.abort(); } Op::ConfigureSession { + provider, model, instructions, approval_policy, @@ -572,7 +573,7 @@ async fn submission_loop( notify, cwd, } => { - info!(model, "Configuring session"); + info!("Configuring session: model={model}; provider={provider:?}"); if !cwd.is_absolute() { let message = format!("cwd is not absolute: {cwd:?}"); error!(message); @@ -586,7 +587,7 @@ async fn submission_loop( return; } - let client = ModelClient::new(model.clone()); + let client = ModelClient::new(model.clone(), provider.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..9e3ef58b68 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -16,10 +16,16 @@ use tokio::sync::Notify; /// is received as a response to the initial `ConfigureSession` submission so /// that callers can surface the information to the UI. pub async fn init_codex(config: Config) -> anyhow::Result<(CodexWrapper, Event, Arc)> { + let provider = config + .providers + .get(&config.provider) + .ok_or_else(|| anyhow::anyhow!("provider {} not found in config", config.provider))?; + let ctrl_c = notify_on_sigint(); let codex = CodexWrapper::new(Codex::spawn(ctrl_c.clone())?); let init_id = codex .submit(Op::ConfigureSession { + provider: 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..a8e532b642 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,5 +1,7 @@ use crate::flags::OPENAI_DEFAULT_MODEL; use crate::mcp_server_config::McpServerConfig; +use crate::model_provider_info::ModelProviderInfo; +use crate::model_provider_info::built_in_model_providers; use crate::protocol::AskForApproval; use crate::protocol::SandboxPermission; use crate::protocol::SandboxPolicy; @@ -19,6 +21,9 @@ pub struct Config { /// Optional override of model selection. pub model: String, + /// Selected provider ("openai", "openrouter", ...) + pub provider: String, + /// Approval policy for executing commands. pub approval_policy: AskForApproval, @@ -61,6 +66,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 +77,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 +104,10 @@ 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. + #[serde(default)] + pub providers: HashMap, } impl ConfigToml { @@ -152,6 +167,8 @@ pub struct ConfigOverrides { pub approval_policy: Option, pub sandbox_policy: Option, pub disable_response_storage: Option, + + pub provider: Option, } impl Config { @@ -176,6 +193,7 @@ impl Config { approval_policy, sandbox_policy, disable_response_storage, + provider, } = overrides; let sandbox_policy = match sandbox_policy { @@ -193,8 +211,17 @@ impl Config { } }; + let mut model_providers = built_in_model_providers(); + // Merge user-defined providers into the built-in list. + for (key, provider) in cfg.providers.into_iter() { + model_providers.entry(key).or_insert(provider); + } + 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 +249,7 @@ impl Config { notify: cfg.notify, instructions, mcp_servers: cfg.mcp_servers, + providers: model_providers, } } diff --git a/codex-rs/core/src/flags.rs b/codex-rs/core/src/flags.rs index 4d0d4bbe47..adc7d264f8 100644 --- a/codex-rs/core/src/flags.rs +++ b/codex-rs/core/src/flags.rs @@ -2,13 +2,13 @@ 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"; + 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) }; @@ -21,9 +21,6 @@ env_flags! { 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 ef671a94d1..1c3a46dfd1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -7,6 +7,7 @@ mod client; pub mod codex; +pub use codex::Codex; pub mod codex_wrapper; pub mod config; pub mod error; @@ -18,6 +19,8 @@ pub mod linux; mod mcp_connection_manager; pub mod mcp_server_config; mod mcp_tool_call; +mod model_provider_info; +pub use model_provider_info::ModelProviderInfo; mod models; pub mod protocol; mod rollout; @@ -25,5 +28,3 @@ mod safety; mod user_notification; pub mod util; mod zdr_transcript; - -pub use codex::Codex; 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..686d7a7aeb --- /dev/null +++ b/codex-rs/core/src/model_provider_info.rs @@ -0,0 +1,124 @@ +//! 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 serde::Serialize; +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, Serialize)] +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 built_in_model_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() +} diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 4796381dbf..bd6a0d8cf9 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -11,6 +11,8 @@ use mcp_types::CallToolResult; use serde::Deserialize; use serde::Serialize; +use crate::model_provider_info::ModelProviderInfo; + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Submission { @@ -27,6 +29,11 @@ pub struct Submission { pub enum Op { /// Configure the model session. ConfigureSession { + /// Provider identifier ("openai", "openrouter", ...). Defaults to + /// "openai" when omitted so that older clients continue to work. + // #[serde(default = "default_provider")] + provider: ModelProviderInfo, + /// 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..baf64d08c6 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: config.providers.get("openai").unwrap().clone(), 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..3f77707eff 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -1,6 +1,7 @@ use std::time::Duration; use codex_core::Codex; +use codex_core::ModelProviderInfo; use codex_core::config::Config; use codex_core::protocol::InputItem; use codex_core::protocol::Op; @@ -82,11 +83,14 @@ async fn keeps_previous_response_id_between_tasks() { // Update environment – `set_var` is `unsafe` starting with the 2024 // edition so we group the calls into a single `unsafe { … }` block. unsafe { - std::env::set_var("OPENAI_API_KEY", "test-key"); - std::env::set_var("OPENAI_API_BASE", server.uri()); std::env::set_var("OPENAI_REQUEST_MAX_RETRIES", "0"); std::env::set_var("OPENAI_STREAM_MAX_RETRIES", "0"); } + let model_provider = ModelProviderInfo { + name: "openai".into(), + base_url: format!("{}/v1", server.uri()), + env_key: "test-key".into(), + }; let codex = Codex::spawn(std::sync::Arc::new(tokio::sync::Notify::new())).unwrap(); @@ -96,6 +100,7 @@ async fn keeps_previous_response_id_between_tasks() { .submit(Submission { id: "init".into(), op: Op::ConfigureSession { + provider: model_provider, 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..e2e0a19fc1 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: config.providers.get("openai").unwrap().clone(), 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) {