diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3a584d948..59d23a4a0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1699,6 +1699,7 @@ dependencies = [ "codex-mcp", "codex-mcp-server", "codex-model-provider", + "codex-models-manager", "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index ba1c321bc..4bd092353 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -37,6 +37,7 @@ codex-features = { workspace = true } codex-login = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } +codex-models-manager = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e8f95d648..849e8b104 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -49,6 +49,7 @@ use crate::mcp_cmd::McpCli; use crate::responses_cmd::ResponsesCommand; use crate::responses_cmd::run_responses_command; +use codex_core::build_models_manager; use codex_core::clear_memory_roots_contents; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -57,6 +58,10 @@ use codex_core::config::find_codex_home; use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; +use codex_models_manager::AuthManager; +use codex_models_manager::bundled_models_response; +use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_models_manager::manager::RefreshStrategy; use codex_protocol::protocol::AskForApproval; use codex_protocol::user_input::UserInput; use codex_terminal_detection::TerminalName; @@ -200,6 +205,9 @@ struct DebugCommand { #[derive(Debug, clap::Subcommand)] enum DebugSubcommand { + /// Render the raw model catalog as JSON. + Models(DebugModelsCommand), + /// Tooling: helps debug the app server. AppServer(DebugAppServerCommand), @@ -240,6 +248,13 @@ struct DebugPromptInputCommand { images: Vec, } +#[derive(Debug, Parser)] +struct DebugModelsCommand { + /// Skip refresh and dump only the bundled catalog shipped with this binary. + #[arg(long = "bundled", default_value_t = false)] + bundled: bool, +} + #[derive(Debug, Parser)] struct ResumeCommand { /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. @@ -990,6 +1005,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } }, Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { + DebugSubcommand::Models(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug models", + )?; + run_debug_models_command(cmd, root_config_overrides).await?; + } DebugSubcommand::AppServer(cmd) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1279,6 +1302,31 @@ async fn run_debug_prompt_input_command( Ok(()) } +async fn run_debug_models_command( + cmd: DebugModelsCommand, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + let catalog = if cmd.bundled { + bundled_models_response()? + } else { + let cli_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let config = Config::load_with_cli_overrides(cli_overrides).await?; + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true); + let models_manager = + build_models_manager(&config, auth_manager, CollaborationModesConfig::default()); + models_manager + .raw_model_catalog(RefreshStrategy::OnlineIfUncached) + .await + }; + + serde_json::to_writer(std::io::stdout(), &catalog)?; + println!(); + Ok(()) +} + async fn run_debug_clear_memories_command( root_config_overrides: &CliConfigOverrides, interactive: &TuiCli, @@ -1701,6 +1749,21 @@ mod tests { ); } + #[test] + fn debug_models_parses_bundled_flag() { + let cli = + MultitoolCli::try_parse_from(["codex", "debug", "models", "--bundled"]).expect("parse"); + + let Some(Subcommand::Debug(DebugCommand { + subcommand: DebugSubcommand::Models(cmd), + })) = cli.subcommand + else { + panic!("expected debug models subcommand"); + }; + + assert!(cmd.bundled); + } + #[test] fn responses_subcommand_is_hidden_from_help_but_parses() { let help = MultitoolCli::command().render_help().to_string(); diff --git a/codex-rs/cli/tests/debug_models.rs b/codex-rs/cli/tests/debug_models.rs new file mode 100644 index 000000000..f927742a5 --- /dev/null +++ b/codex-rs/cli/tests/debug_models.rs @@ -0,0 +1,40 @@ +use std::path::Path; + +use anyhow::Result; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn debug_models_bundled_prints_json() -> Result<()> { + let codex_home = TempDir::new()?; + let mut cmd = codex_command(codex_home.path())?; + let output = cmd.args(["debug", "models", "--bundled"]).output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + let value: serde_json::Value = serde_json::from_str(&stdout)?; + assert!(value["models"].is_array()); + assert!(!value["models"].as_array().unwrap_or(&Vec::new()).is_empty()); + + Ok(()) +} + +#[test] +fn debug_models_default_prints_json_without_auth() -> Result<()> { + let codex_home = TempDir::new()?; + let mut cmd = codex_command(codex_home.path())?; + let output = cmd.args(["debug", "models"]).output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + let value: serde_json::Value = serde_json::from_str(&stdout)?; + assert!(value["models"].is_array()); + assert!(!value["models"].as_array().unwrap_or(&Vec::new()).is_empty()); + + Ok(()) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1fef33c3f..1809789a8 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -122,6 +122,7 @@ pub(crate) mod windows_sandbox_read_grants; pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; +pub use thread_manager::build_models_manager; pub use web_search::web_search_action_detail; pub use web_search::web_search_detail; pub use windows_sandbox_read_grants::grant_read_root_non_elevated; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 03bf768ac..e4da99bb5 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -217,6 +217,26 @@ pub(crate) struct ThreadManagerState { ops_log: Option, } +pub fn build_models_manager( + config: &Config, + auth_manager: Arc, + collaboration_modes_config: CollaborationModesConfig, +) -> Arc { + let openai_models_provider = config + .model_providers + .get(OPENAI_PROVIDER_ID) + .cloned() + .unwrap_or_else(|| ModelProviderInfo::create_openai_provider(/*base_url*/ None)); + + Arc::new(ModelsManager::new_with_provider( + config.codex_home.to_path_buf(), + auth_manager, + config.model_catalog.clone(), + collaboration_modes_config, + openai_models_provider, + )) +} + impl ThreadManager { pub fn new( config: &Config, @@ -228,11 +248,6 @@ impl ThreadManager { ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); - let openai_models_provider = config - .model_providers - .get(OPENAI_PROVIDER_ID) - .cloned() - .unwrap_or_else(|| ModelProviderInfo::create_openai_provider(/*base_url*/ None)); let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); let plugins_manager = Arc::new(PluginsManager::new_with_restriction_product( codex_home.to_path_buf(), @@ -240,7 +255,7 @@ impl ThreadManager { )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( - codex_home.clone(), + codex_home, config.bundled_skills_enabled(), restriction_product, )); @@ -249,13 +264,11 @@ impl ThreadManager { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), thread_created_tx, - models_manager: Arc::new(ModelsManager::new_with_provider( - codex_home.to_path_buf(), + models_manager: build_models_manager( + config, auth_manager.clone(), - config.model_catalog.clone(), collaboration_modes_config, - openai_models_provider, - )), + ), environment_manager, skills_manager, plugins_manager, diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index 4b0e2ca77..c029960a7 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -253,6 +253,16 @@ impl ModelsManager { self.build_available_models(remote_models) } + /// Return the active raw model catalog, refreshing according to the specified strategy. + pub async fn raw_model_catalog(&self, refresh_strategy: RefreshStrategy) -> ModelsResponse { + if let Err(err) = self.refresh_available_models(refresh_strategy).await { + error!("failed to refresh available models: {err}"); + } + ModelsResponse { + models: self.get_remote_models().await, + } + } + /// List collaboration mode presets. /// /// Returns a static set of presets seeded with the configured model.