diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 65810a61bf7..38c469b55a9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.49.0" dependencies = [ "keyring", "tracing", @@ -1495,7 +1495,7 @@ dependencies = [ [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.49.0" dependencies = [ "lru", "sha1", @@ -1504,7 +1504,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.49.0" dependencies = [ "base64", "codex-utils-cache", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4f8c066f7e0..9001dc93610 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1675,6 +1675,7 @@ async fn derive_config_from_params( tools_web_search_request: None, experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), + browseros_config: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f671e8dd363..4d224a537e7 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1162,6 +1162,8 @@ pub struct ConfigOverrides { pub experimental_sandbox_command_assessment: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Path to BrowserOS provider config file (TOML). + pub browseros_config: Option, } impl Config { @@ -1191,6 +1193,7 @@ impl Config { tools_web_search_request: override_tools_web_search_request, experimental_sandbox_command_assessment: sandbox_command_assessment_override, additional_writable_roots, + browseros_config, } = overrides; let active_profile_name = config_profile_key @@ -1292,10 +1295,85 @@ impl Config { model_providers.entry(key).or_insert(provider); } - let model_provider_id = model_provider - .or(config_profile.model_provider) - .or(cfg.model_provider) - .unwrap_or_else(|| "openai".to_string()); + // Load BrowserOS provider if config file is specified. + let mut browseros_model_name = None; + let mut browseros_mcp_servers = HashMap::new(); + let mut browseros_base_instructions_file = None; + if let Some(browseros_config_path) = &browseros_config { + use crate::model_provider_info::load_browseros_provider; + use crate::model_provider_info::BUILT_IN_BROWSEROS_MODEL_PROVIDER_ID; + use crate::model_provider_info::BrowserOSConfig; + + // Load the full BrowserOS config to extract model_name, MCP servers, and base_instructions_file + let full_path = if browseros_config_path.is_relative() { + resolved_cwd.join(browseros_config_path) + } else { + browseros_config_path.clone() + }; + let contents = std::fs::read_to_string(&full_path).map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to read BrowserOS config file {}: {e}", full_path.display()), + ) + })?; + let browseros_cfg: BrowserOSConfig = toml::from_str(&contents).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "failed to parse BrowserOS config file {}: {e}", + full_path.display() + ), + ) + })?; + + // Extract model_name for later use + browseros_model_name = browseros_cfg.model_name.clone(); + + // Extract base_instructions_file path for later use + // Resolve relative paths relative to the BrowserOS config file's directory + if let Some(instructions_path) = browseros_cfg.base_instructions_file { + let resolved_instructions_path = if instructions_path.is_relative() { + full_path + .parent() + .unwrap_or(&resolved_cwd) + .join(&instructions_path) + } else { + instructions_path + }; + browseros_base_instructions_file = Some(resolved_instructions_path); + } + + // Extract MCP servers from BrowserOS config + if let Some(mcp_servers_value) = browseros_cfg.mcp_servers { + for (name, server_value) in mcp_servers_value { + // Deserialize MCP server config from TOML value + match toml::from_str::(&toml::to_string(&server_value).unwrap_or_default()) { + Ok(server_config) => { + browseros_mcp_servers.insert(name, server_config); + } + Err(e) => { + tracing::warn!( + "Failed to parse MCP server '{name}' from BrowserOS config: {e}" + ); + } + } + } + } + + // Load and register the BrowserOS provider + let browseros_provider = load_browseros_provider(browseros_config_path, &resolved_cwd)?; + model_providers.insert(BUILT_IN_BROWSEROS_MODEL_PROVIDER_ID.to_string(), browseros_provider); + } + + // Determine model provider ID: BrowserOS override takes precedence if configured + let model_provider_id = if browseros_config.is_some() { + crate::model_provider_info::BUILT_IN_BROWSEROS_MODEL_PROVIDER_ID.to_string() + } else { + model_provider + .or(config_profile.model_provider) + .or(cfg.model_provider) + .unwrap_or_else(|| "openai".to_string()) + }; let model_provider = model_providers .get(&model_provider_id) .ok_or_else(|| { @@ -1331,7 +1409,9 @@ impl Config { let forced_login_method = cfg.forced_login_method; + // Model resolution: BrowserOS config model_name takes precedence if provided let model = model + .or(browseros_model_name) .or(config_profile.model) .or(cfg.model) .unwrap_or_else(default_model); @@ -1361,16 +1441,22 @@ impl Config { .and_then(|info| info.auto_compact_token_limit) }); - // Load base instructions override from a file if specified. If the - // path is relative, resolve it against the effective cwd so the - // behaviour matches other path-like config values. + // Load base instructions override from a file if specified. Precedence: + // 1. CLI override (base_instructions from ConfigOverrides) - highest + // 2. BrowserOS config base_instructions_file + // 3. config.toml experimental_instructions_file + // 4. Model family default - lowest let experimental_instructions_path = config_profile .experimental_instructions_file .as_ref() .or(cfg.experimental_instructions_file.as_ref()); let file_base_instructions = Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?; - let base_instructions = base_instructions.or(file_base_instructions); + let browseros_base_instructions = + Self::get_base_instructions(browseros_base_instructions_file.as_ref(), &resolved_cwd)?; + let base_instructions = base_instructions + .or(browseros_base_instructions) + .or(file_base_instructions); // Default review model when not set in config; allow CLI override to take precedence. let review_model = override_review_model @@ -1398,7 +1484,14 @@ impl Config { // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), - mcp_servers: cfg.mcp_servers, + mcp_servers: { + // Merge MCP servers: BrowserOS config MCP servers take precedence over global config + let mut merged_mcp_servers = cfg.mcp_servers; + for (name, server_config) in browseros_mcp_servers { + merged_mcp_servers.insert(name, server_config); + } + merged_mcp_servers + }, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index f7d86c41a5d..ded3115d909 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -42,6 +42,7 @@ pub mod token_data; mod truncate; mod unified_exec; mod user_instructions; +pub use model_provider_info::BUILT_IN_BROWSEROS_MODEL_PROVIDER_ID; pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID; pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::WireApi; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index d1e6caf8285..d23170a61e2 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -13,6 +13,7 @@ use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; use std::env::VarError; +use std::path::PathBuf; use std::time::Duration; use crate::error::EnvVarError; @@ -261,6 +262,7 @@ impl ModelProviderInfo { const DEFAULT_OLLAMA_PORT: u32 = 11434; pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss"; +pub const BUILT_IN_BROWSEROS_MODEL_PROVIDER_ID: &str = "browseros"; /// Built-in default provider list. pub fn built_in_model_providers() -> HashMap { @@ -286,7 +288,7 @@ pub fn built_in_model_providers() -> HashMap { env_key: None, env_key_instructions: None, experimental_bearer_token: None, - wire_api: WireApi::Chat, + wire_api: WireApi::Responses, query_params: None, http_headers: Some( [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] @@ -357,6 +359,102 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo { } } +/// BrowserOS provider configuration loaded from a TOML file. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct BrowserOSConfig { + /// Model name to use (e.g., "gpt-4o", "o3"). + pub model_name: Option, + /// Base URL for the API. If not set, will try BROWSEROS_BASE_URL env var. + pub base_url: Option, + /// Environment variable name that contains the API key. + /// If not set, will try BROWSEROS_API_KEY env var. + pub api_key_env: Option, + /// Wire API protocol to use: "chat" or "responses". + #[serde(default)] + pub wire_api: WireApi, + /// Additional query parameters for the base URL. + pub query_params: Option>, + /// Additional HTTP headers (static values). + pub http_headers: Option>, + /// HTTP headers sourced from environment variables. + pub env_http_headers: Option>, + /// Maximum number of request retries. + pub request_max_retries: Option, + /// Maximum number of stream reconnection attempts. + pub stream_max_retries: Option, + /// Idle timeout for streaming responses (milliseconds). + pub stream_idle_timeout_ms: Option, + /// MCP servers to enable (same structure as config.toml mcp_servers). + #[serde(default)] + pub mcp_servers: Option>, + /// Path to a file containing base instructions (system prompt) for the LLM. + /// If relative, will be resolved relative to the BrowserOS config file's directory. + pub base_instructions_file: Option, +} + +/// Load BrowserOS provider configuration from a TOML file. +pub fn load_browseros_provider( + config_path: &std::path::Path, + cwd: &std::path::Path, +) -> std::io::Result { + // Resolve relative paths against cwd + let full_path = if config_path.is_relative() { + cwd.join(config_path) + } else { + config_path.to_path_buf() + }; + + let contents = std::fs::read_to_string(&full_path).map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to read BrowserOS config file {}: {e}", full_path.display()), + ) + })?; + + let browseros_cfg: BrowserOSConfig = toml::from_str(&contents).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "failed to parse BrowserOS config file {}: {e}", + full_path.display() + ), + ) + })?; + + // Resolve base_url: use config value, then env var, then error + let base_url = browseros_cfg.base_url.or_else(|| { + std::env::var("BROWSEROS_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()) + }).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "BrowserOS config must specify base_url or set BROWSEROS_BASE_URL environment variable", + ) + })?; + + // Resolve API key env var name + let api_key_env = browseros_cfg + .api_key_env + .unwrap_or_else(|| "BROWSEROS_API_KEY".to_string()); + + Ok(ModelProviderInfo { + name: "BrowserOS".into(), + base_url: Some(base_url), + env_key: Some(api_key_env), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: browseros_cfg.wire_api, + query_params: browseros_cfg.query_params, + http_headers: browseros_cfg.http_headers, + env_http_headers: browseros_cfg.env_http_headers, + request_max_retries: browseros_cfg.request_max_retries, + stream_max_retries: browseros_cfg.stream_max_retries, + stream_idle_timeout_ms: browseros_cfg.stream_idle_timeout_ms, + requires_openai_auth: false, + }) +} + fn matches_azure_responses_base_url(base_url: &str) -> bool { let base = base_url.to_ascii_lowercase(); const AZURE_MARKERS: [&str; 5] = [ diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index f56d07dc321..cf7052b40ab 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -21,6 +21,12 @@ pub struct Cli { #[arg(long = "oss", default_value_t = false)] pub oss: bool, + /// Select the BrowserOS model provider using a config file. + /// Loads provider configuration (base_url, api_key, model_name, MCP servers, etc.) + /// from the specified TOML file. Similar to --oss but uses a custom config file. + #[arg(long = "browseros", value_name = "CONFIG_FILE", value_hint = clap::ValueHint::FilePath)] + pub browseros_config: Option, + /// Select the sandbox policy to use when executing model-generated shell /// commands. #[arg(long = "sandbox", short = 's', value_enum)] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 559fd980024..76312a9353d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -58,6 +58,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any images, model: model_cli_arg, oss, + browseros_config, config_profile, full_auto, dangerously_bypass_approvals_and_sandbox, @@ -149,6 +150,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any // When using `--oss`, let the bootstrapper pick the model (defaulting to // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in // `oss` model provider. + // When using `--browseros`, BrowserOS config takes precedence over model and model_provider. let model = if let Some(model) = model_cli_arg { Some(model) } else if oss { @@ -157,7 +159,9 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any None // No model specified, will use the default. }; - let model_provider = if oss { + let model_provider = if browseros_config.is_some() { + Some(codex_core::BUILT_IN_BROWSEROS_MODEL_PROVIDER_ID.to_string()) + } else if oss { Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_string()) } else { None // No specific model provider override. @@ -181,6 +185,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any tools_web_search_request: None, experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), + browseros_config, }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 24a5eec4b89..c21aa27e3d2 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -160,6 +160,7 @@ impl CodexToolCallParam { tools_web_search_request: None, experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), + browseros_config: None, }; let cli_overrides = cli_overrides diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index d86040b5c98..79dcd1f9f3b 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -38,6 +38,12 @@ pub struct Cli { #[arg(long = "oss", default_value_t = false)] pub oss: bool, + /// Select the BrowserOS model provider using a config file. + /// Loads provider configuration (base_url, api_key, model_name, MCP servers, etc.) + /// from the specified TOML file. Similar to --oss but uses a custom config file. + #[arg(long = "browseros", value_name = "CONFIG_FILE", value_hint = clap::ValueHint::FilePath)] + pub browseros_config: Option, + /// Configuration profile from config.toml to specify default options. #[arg(long = "profile", short = 'p')] pub config_profile: Option, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 028bf68e87f..2cdf1d0637d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -150,6 +150,7 @@ pub async fn run_main( tools_web_search_request: cli.web_search.then_some(true), experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, + browseros_config: cli.browseros_config.clone(), }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); let overrides_cli = codex_common::CliConfigOverrides { raw_overrides };