Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
2422660594 feat: list-models subcommand for full CLI 2025-06-09 16:27:56 -04:00
7 changed files with 159 additions and 1 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -600,6 +600,7 @@ version = "0.0.0"
dependencies = [
"clap",
"codex-core",
"reqwest",
"serde",
"toml",
]

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" }

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(())
}
}

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(())

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"]

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;

View File

@@ -0,0 +1,73 @@
//! 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::ModelProviderInfo;
use codex_core::error::CodexErr;
use codex_core::error::Result;
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.error_for_status() {
Ok(ok_resp) => {
// Guaranteed 2xx
let json: ModelsResponse = ok_resp.json().await?;
let mut models: Vec<String> = json.data.into_iter().map(|m| m.id).collect();
models.sort();
Ok(models)
}
Err(err) => Err(CodexErr::Reqwest(err)),
}
}