Merge 5d8ec161e8 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-06-09 13:12:49 -07:00
committed by GitHub
11 changed files with 184 additions and 22 deletions
+1
View File
@@ -600,6 +600,7 @@ version = "0.0.0"
dependencies = [
"clap",
"codex-core",
"reqwest",
"serde",
"toml",
]
+1 -1
View File
@@ -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" }
+55
View File
@@ -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<String>,
/// 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(())
}
}
+12
View File
@@ -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<PathBuf>) -> 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(())
+7
View File
@@ -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"]
+10
View File
@@ -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;
+74
View File
@@ -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<ModelId>,
}
#[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<Vec<String>> {
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<String> = json.data.into_iter().map(|m| m.id).collect();
models.sort();
Ok(models)
}
_ => Err(CodexErr::Reqwest(resp.error_for_status().unwrap_err())),
}
}
+22 -8
View File
@@ -40,6 +40,9 @@ pub(crate) struct App<'a> {
app_event_rx: Receiver<AppEvent>,
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<ChatWidgetArgs>,
@@ -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}");
-5
View File
@@ -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 {
@@ -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,
+2 -2
View File
@@ -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)"
}