diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 66b4fa3e00..87854aad32 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -600,6 +600,7 @@ version = "0.0.0" dependencies = [ "clap", "codex-core", + "reqwest", "serde", "toml", ] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 78fd08a7d3..663a4228e9 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -18,7 +18,7 @@ workspace = true anyhow = "1" clap = { version = "4", features = ["derive"] } codex-core = { path = "../core" } -codex-common = { path = "../common", features = ["cli"] } +codex-common = { path = "../common", features = ["cli", "model-list"] } codex-exec = { path = "../exec" } codex-login = { path = "../login" } codex-linux-sandbox = { path = "../linux-sandbox" } diff --git a/codex-rs/cli/src/list_models.rs b/codex-rs/cli/src/list_models.rs new file mode 100644 index 0000000000..3d3b2789d6 --- /dev/null +++ b/codex-rs/cli/src/list_models.rs @@ -0,0 +1,55 @@ +use clap::Parser; + +use codex_common::CliConfigOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; + +/// Print the list of models available for the configured (or overridden) +/// provider. +#[derive(Debug, Parser)] +pub struct ListModelsCli { + /// Optional provider override. When set this value is used instead of the + /// `model_provider_id` configured in `~/.codex/config.toml`. + #[clap(long)] + pub provider: Option, + + /// Arbitrary `-c key=value` overrides that apply **in addition** to the + /// `--provider` flag. + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, +} + +impl ListModelsCli { + pub async fn run(self) -> anyhow::Result<()> { + // Compose strongly-typed overrides. The provider flag, if specified, + // is translated into the corresponding field inside `ConfigOverrides`. + let overrides = ConfigOverrides { + model: None, + config_profile: None, + approval_policy: None, + sandbox_policy: None, + cwd: None, + model_provider: self.provider.clone(), + codex_linux_sandbox_exe: None, + }; + + // Parse the raw `-c` overrides early so we can bail with a useful + // error message if the user supplied an invalid value. + let cli_kv_overrides = self + .config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + + // Load the merged configuration. + let cfg = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?; + + // Retrieve the model list. + let models = codex_common::fetch_available_models(cfg.model_provider).await?; + + for m in models { + println!("{m}"); + } + + Ok(()) + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 0e9ba01827..5ec2d27da9 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -9,6 +9,7 @@ use codex_tui::Cli as TuiCli; use std::path::PathBuf; use crate::proto::ProtoCli; +mod list_models; /// Codex CLI /// @@ -43,6 +44,10 @@ enum Subcommand { /// Experimental: run Codex as an MCP server. Mcp, + /// List models for the configured or specified provider. + #[clap(name = "list-models", visible_alias = "lm")] + ListModels(crate::list_models::ListModelsCli), + /// Run the Protocol stream via stdin/stdout #[clap(visible_alias = "p")] Proto(ProtoCli), @@ -121,6 +126,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() .await?; } }, + Some(Subcommand::ListModels(list_cli)) => { + // Combine root-level overrides with subcommand-specific ones so + // that the latter take precedence. + let mut list_cli = list_cli; + prepend_config_flags(&mut list_cli.config_overrides, cli.config_overrides); + list_cli.run().await?; + } } Ok(()) diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index b4b658dabf..390c20be78 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -11,8 +11,15 @@ clap = { version = "4", features = ["derive", "wrap_help"], optional = true } codex-core = { path = "../core" } toml = { version = "0.8", optional = true } serde = { version = "1", optional = true } +reqwest = { version = "0.12", features = ["json"], optional = true } [features] # Separate feature so that `clap` is not a mandatory dependency. cli = ["clap", "toml", "serde"] elapsed = [] + +# Helper functionality for querying the list of available models from a model +# provider. This is intentionally behind a separate opt-in feature so that +# downstream crates that do not need it avoid pulling in the additional heavy +# dependencies (`reqwest`, etc.). +model-list = ["reqwest"] diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index c2283640cb..2bcd505fc3 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -14,3 +14,13 @@ mod config_override; #[cfg(feature = "cli")] pub use config_override::CliConfigOverrides; + +// ------------------------------------------------------------------------- +// Optional helpers for querying the list of available models. +// ------------------------------------------------------------------------- + +#[cfg(feature = "model-list")] +mod model_list; + +#[cfg(feature = "model-list")] +pub use model_list::fetch_available_models; diff --git a/codex-rs/common/src/model_list.rs b/codex-rs/common/src/model_list.rs new file mode 100644 index 0000000000..870cfd82b2 --- /dev/null +++ b/codex-rs/common/src/model_list.rs @@ -0,0 +1,74 @@ +//! Helper for fetching the list of models that are available for a given +//! [`ModelProviderInfo`] instance. +//! +//! The implementation is intentionally lightweight and only covers the subset +//! of the OpenAI-compatible REST API that is required to discover available +//! model *identifiers*. At the time of writing all providers supported by +//! Codex expose a `GET /models` endpoint that returns a JSON payload in the +//! following canonical form: +//! +//! ```json +//! { +//! "object": "list", +//! "data": [ +//! { "id": "o3", "object": "model" }, +//! { "id": "o4-mini", "object": "model" } +//! ] +//! } +//! ``` +//! +//! We purposefully parse *only* the `id` fields that callers care about and +//! ignore any additional metadata so that the function keeps working even if +//! upstream providers add new attributes. + +use codex_core::error::{CodexErr, Result}; +use codex_core::ModelProviderInfo; +use reqwest::StatusCode; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct ModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelId { + id: String, +} + +/// Fetch the list of available model identifiers from the given provider. +/// +/// The caller must ensure that the provider's API key can be resolved via +/// [`ModelProviderInfo::api_key`] – if this fails the function returns a +/// [`CodexErr::EnvVar`]. Any network or JSON parsing failures are forwarded +/// to the caller. +#[allow(clippy::needless_pass_by_value)] +pub async fn fetch_available_models(provider: ModelProviderInfo) -> Result> { + let api_key = provider.api_key()?; + + let base_url = provider.base_url.trim_end_matches('/'); + let url = format!("{base_url}/models"); + + // Build the request. For providers that require authentication we send + // the token via the standard Bearer mechanism. Providers like Ollama do + // not require a token – in that case we just omit the header. + let client = reqwest::Client::new(); + let mut req = client.get(&url); + if let Some(token) = api_key { + req = req.bearer_auth(token); + } + + + + let resp = req.send().await?; + + match resp.status() { + StatusCode::OK => { + let json: ModelsResponse = resp.json().await?; + let mut models: Vec = json.data.into_iter().map(|m| m.id).collect(); + models.sort(); + Ok(models) + } + _ => Err(CodexErr::Reqwest(resp.error_for_status().unwrap_err())), + } +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8f35a3507f..ff61b5c941 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -40,6 +40,9 @@ pub(crate) struct App<'a> { app_event_rx: Receiver, app_state: AppState<'a>, + /// Config is stored here so we can recreate ChatWidgets as needed. + config: Config, + /// Stored parameters needed to instantiate the ChatWidget later, e.g., /// after dismissing the Git-repo warning. chat_args: Option, @@ -122,7 +125,7 @@ impl<'a> App<'a> { screen: LoginScreen::new(app_event_tx.clone(), config.codex_home.clone()), }, Some(ChatWidgetArgs { - config, + config: config.clone(), initial_prompt, initial_images, }), @@ -133,14 +136,18 @@ impl<'a> App<'a> { screen: GitWarningScreen::new(), }, Some(ChatWidgetArgs { - config, + config: config.clone(), initial_prompt, initial_images, }), ) } else { - let chat_widget = - ChatWidget::new(config, app_event_tx.clone(), initial_prompt, initial_images); + let chat_widget = ChatWidget::new( + config.clone(), + app_event_tx.clone(), + initial_prompt, + initial_images, + ); ( AppState::Chat { widget: Box::new(chat_widget), @@ -153,6 +160,7 @@ impl<'a> App<'a> { app_event_tx, app_event_rx, app_state, + config, chat_args, } } @@ -224,10 +232,16 @@ impl<'a> App<'a> { AppState::Login { .. } | AppState::GitWarning { .. } => {} }, AppEvent::DispatchCommand(command) => match command { - SlashCommand::Clear => match &mut self.app_state { - AppState::Chat { widget } => widget.clear_conversation_history(), - AppState::Login { .. } | AppState::GitWarning { .. } => {} - }, + SlashCommand::New => { + let new_widget = Box::new(ChatWidget::new( + self.config.clone(), + self.app_event_tx.clone(), + None, + Vec::new(), + )); + self.app_state = AppState::Chat { widget: new_widget }; + self.app_event_tx.send(AppEvent::Redraw); + } SlashCommand::ToggleMouseMode => { if let Err(e) = mouse_capture.toggle() { tracing::error!("Failed to toggle mouse mode: {e}"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63f3bc727a..bd5197c73b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -207,11 +207,6 @@ impl ChatWidget<'_> { self.conversation_history.scroll_to_bottom(); } - pub(crate) fn clear_conversation_history(&mut self) { - self.conversation_history.clear(); - self.request_redraw(); - } - pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; match msg { diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index a23e00d776..714ac074a7 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -245,12 +245,6 @@ impl ConversationHistoryWidget { }); } - /// Remove all history entries and reset scrolling. - pub fn clear(&mut self) { - self.entries.clear(); - self.scroll_position = usize::MAX; - } - pub fn record_completed_exec_command( &mut self, call_id: String, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index cd6da4dac9..bfc02ceb13 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -12,7 +12,7 @@ use strum_macros::IntoStaticStr; )] #[strum(serialize_all = "kebab-case")] pub enum SlashCommand { - Clear, + New, ToggleMouseMode, Quit, } @@ -21,7 +21,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { - SlashCommand::Clear => "Clear the chat history.", + SlashCommand::New => "Start a new chat.", SlashCommand::ToggleMouseMode => { "Toggle mouse mode (enable for scrolling, disable for text selection)" }