diff --git a/README.md b/README.md index ae04239ae6f..132552ca3ed 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,34 @@ Codex CLI supports [MCP servers](./docs/advanced.md#model-context-protocol-mcp). Codex CLI supports a rich set of configuration options, with preferences stored in `~/.codex/config.toml`. For full configuration options, see [Configuration](./docs/config.md). +### Using Codex with LM Studio + +Codex can run fully locally by delegating inference to [LM Studio](https://lmstudio.ai/). + +1. Launch LM Studio and enable the **Local Inference Server** (Preferences → Developer). +2. Start any LM Studio model from the **My Models** tab. Codex looks for the model identifier exposed by the LM Studio server. +3. Run Codex with the LM Studio backend: + + ```shell + # Interactive session using the default LLaMA 3.1 8B Instruct model + codex --backend lmstudio + + # Explicitly pick one of the supported architectures + codex --backend lmstudio --model qwen3 + codex exec --backend lmstudio --model qwen3-moe "summarize this repo" + ``` + +Codex understands the following architecture aliases when `--backend lmstudio` is selected: + +| Alias | LM Studio model identifier | +| ---------- | --------------------------------------------------- | +| `llama` | `meta-llama/Meta-Llama-3.1-8B-Instruct` | +| `qwen2` | `Qwen/Qwen2-7B-Instruct` | +| `qwen3` | `Qwen/Qwen3-7B-Instruct` | +| `qwen3-moe`| `Qwen/Qwen3-MoE-A2.7B-Instruct` | + +You can also pass the exact LM Studio identifier (for example `my-org/custom-model`) if you are running a different checkpoint. Codex verifies that the requested model is available from LM Studio and surfaces clear errors when it is not. + --- ### Docs & FAQ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb97878072c..f1893d65b55 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -792,6 +792,7 @@ dependencies = [ "codex-arg0", "codex-common", "codex-core", + "codex-lmstudio", "codex-ollama", "codex-protocol", "core_test_support", @@ -868,6 +869,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-lmstudio" +version = "0.0.0" +dependencies = [ + "codex-core", + "pretty_assertions", + "reqwest", + "serde_json", + "tempfile", + "tokio", + "tracing", + "wiremock", +] + [[package]] name = "codex-login" version = "0.0.0" @@ -1032,6 +1047,7 @@ dependencies = [ "codex-core", "codex-file-search", "codex-git-tooling", + "codex-lmstudio", "codex-login", "codex-ollama", "codex-protocol", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index af111c6f196..237f1994c14 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -14,6 +14,7 @@ members = [ "login", "mcp-client", "mcp-server", + "lmstudio", "mcp-types", "ollama", "protocol", @@ -50,6 +51,7 @@ codex-mcp-client = { path = "mcp-client" } codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } codex-protocol = { path = "protocol" } +codex-lmstudio = { path = "lmstudio" } codex-rmcp-client = { path = "rmcp-client" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } diff --git a/codex-rs/common/src/backend_cli_arg.rs b/codex-rs/common/src/backend_cli_arg.rs new file mode 100644 index 00000000000..ba32ea19d4e --- /dev/null +++ b/codex-rs/common/src/backend_cli_arg.rs @@ -0,0 +1,36 @@ +use clap::ValueEnum; + +/// CLI flag values for selecting the Codex runtime backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum BackendCliArg { + /// Use the default OpenAI backend. + Openai, + /// Use the bundled open-source Ollama integration. + Oss, + /// Use a local LM Studio instance. + Lmstudio, +} + +impl BackendCliArg { + /// Returns the model provider key associated with this backend, if any. + pub fn provider_key(self) -> Option<&'static str> { + match self { + BackendCliArg::Openai => None, + BackendCliArg::Oss => Some(codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID), + BackendCliArg::Lmstudio => Some(codex_core::BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID), + } + } + + pub fn is_oss(self) -> bool { + matches!(self, BackendCliArg::Oss) + } + + pub fn is_lmstudio(self) -> bool { + matches!(self, BackendCliArg::Lmstudio) + } + + pub fn is_local(self) -> bool { + matches!(self, BackendCliArg::Oss | BackendCliArg::Lmstudio) + } +} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 292503f77e4..8f69a2b068c 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -1,3 +1,9 @@ +#[cfg(feature = "cli")] +mod backend_cli_arg; + +#[cfg(feature = "cli")] +pub use backend_cli_arg::BackendCliArg; + #[cfg(feature = "cli")] mod approval_mode_cli_arg; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ad040ec8552..99398d453dd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -39,10 +39,12 @@ pub mod parse_command; mod truncate; mod unified_exec; mod user_instructions; +pub use model_provider_info::BUILT_IN_LM_STUDIO_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; pub use model_provider_info::built_in_model_providers; +pub use model_provider_info::create_lmstudio_provider_with_base_url; pub use model_provider_info::create_oss_provider_with_base_url; mod conversation_manager; mod event_mapping; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 2850996dcfc..d1ecaa8cfd4 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -248,8 +248,10 @@ impl ModelProviderInfo { } const DEFAULT_OLLAMA_PORT: u32 = 11434; +const DEFAULT_LM_STUDIO_PORT: u32 = 1234; pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss"; +pub const BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID: &str = "lmstudio"; /// Built-in default provider list. pub fn built_in_model_providers() -> HashMap { @@ -257,8 +259,9 @@ pub fn built_in_model_providers() -> HashMap { // We do not want to be in the business of adjucating which third-party // providers are bundled with Codex CLI, so we only include the OpenAI and - // open source ("oss") providers by default. Users are encouraged to add to - // `model_providers` in config.toml to add their own providers. + // local open source providers (Ollama "oss" and LM Studio) by default. Users + // are encouraged to add to `model_providers` in config.toml to add their own + // providers. [ ( "openai", @@ -300,6 +303,10 @@ pub fn built_in_model_providers() -> HashMap { }, ), (BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()), + ( + BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID, + create_lmstudio_provider(), + ), ] .into_iter() .map(|(k, v)| (k.to_string(), v)) @@ -344,6 +351,45 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo { } } +pub fn create_lmstudio_provider() -> ModelProviderInfo { + let base_url = match std::env::var("CODEX_LM_STUDIO_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()) + { + Some(url) => url, + None => format!( + "http://localhost:{port}/v1", + port = std::env::var("CODEX_LM_STUDIO_PORT") + .ok() + .filter(|v| !v.trim().is_empty()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_LM_STUDIO_PORT) + ), + }; + + create_lmstudio_provider_with_base_url(&base_url) +} + +pub fn create_lmstudio_provider_with_base_url(base_url: &str) -> ModelProviderInfo { + ModelProviderInfo { + name: "LM Studio".into(), + base_url: Some(base_url.into()), + env_key: None, + env_key_instructions: Some( + "Launch LM Studio and enable the local inference server (Preferences → Developer → Enable local server)." + .into(), + ), + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + 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] = [ @@ -386,6 +432,43 @@ base_url = "http://localhost:11434/v1" assert_eq!(expected_provider, provider); } + #[test] + fn test_deserialize_lmstudio_model_provider_toml() { + let provider_toml = r#" +name = "LM Studio" +base_url = "http://localhost:1234/v1" + "#; + let expected_provider = ModelProviderInfo { + name: "LM Studio".into(), + base_url: Some("http://localhost:1234/v1".into()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + }; + + let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap(); + assert_eq!(expected_provider, provider); + } + + #[test] + fn test_create_lmstudio_provider_with_base_url() { + let provider = create_lmstudio_provider_with_base_url("http://localhost:9999/v1"); + assert_eq!(provider.name, "LM Studio"); + assert_eq!( + provider.base_url.as_deref(), + Some("http://localhost:9999/v1") + ); + assert_eq!(provider.wire_api, WireApi::Chat); + assert!(!provider.requires_openai_auth); + } + #[test] fn test_deserialize_azure_model_provider_toml() { let azure_provider_toml = r#" diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 8603e8fd89b..023000005a3 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -25,6 +25,7 @@ codex-common = { workspace = true, features = [ "sandbox_summary", ] } codex-core = { workspace = true } +codex-lmstudio = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 0df114cb00b..07065a402de 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,5 +1,6 @@ use clap::Parser; use clap::ValueEnum; +use codex_common::BackendCliArg; use codex_common::CliConfigOverrides; use std::path::PathBuf; @@ -18,7 +19,11 @@ pub struct Cli { #[arg(long, short = 'm')] pub model: Option, - #[arg(long = "oss", default_value_t = false)] + /// Select the runtime backend Codex should connect to. + #[arg(long = "backend", value_enum, conflicts_with = "oss")] + pub backend: Option, + + #[arg(long = "oss", default_value_t = false, conflicts_with = "backend")] pub oss: bool, /// Select the sandbox policy to use when executing model-generated shell diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index da23fb1b230..2299cb0f66a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -10,8 +10,8 @@ use std::io::Read; use std::path::PathBuf; pub use cli::Cli; +use codex_common::BackendCliArg; use codex_core::AuthManager; -use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::config::Config; @@ -23,6 +23,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::TaskCompleteEvent; +use codex_lmstudio::DEFAULT_LM_STUDIO_MODEL; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use event_processor_with_human_output::EventProcessorWithHumanOutput; @@ -44,6 +45,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any command, images, model: model_cli_arg, + backend, oss, config_profile, full_auto, @@ -136,22 +138,37 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any sandbox_mode_cli_arg.map(Into::::into) }; - // 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. - let model = if let Some(model) = model_cli_arg { + // Determine which backend to target and derive defaults accordingly. + let backend_choice = backend.unwrap_or(if oss { + BackendCliArg::Oss + } else { + BackendCliArg::Openai + }); + let using_oss = backend_choice.is_oss(); + let using_lmstudio = backend_choice.is_lmstudio(); + + let mut model = if let Some(model) = model_cli_arg { Some(model) - } else if oss { + } else if using_oss { Some(DEFAULT_OSS_MODEL.to_owned()) + } else if using_lmstudio { + Some(DEFAULT_LM_STUDIO_MODEL.to_owned()) } else { None // No model specified, will use the default. }; - let model_provider = if oss { - Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_string()) - } else { - None // No specific model provider override. - }; + if using_lmstudio { + let resolved = match codex_lmstudio::resolve_model_identifier(model.as_deref()) { + Ok(model) => model, + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + }; + model = Some(resolved); + } + + let model_provider = backend_choice.provider_key().map(ToString::to_string); // Load configuration and determine approval policy let overrides = ConfigOverrides { @@ -169,7 +186,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_plan_tool: Some(include_plan_tool), include_apply_patch_tool: None, include_view_image_tool: None, - show_raw_agent_reasoning: oss.then_some(true), + show_raw_agent_reasoning: using_oss.then_some(true), tools_web_search_request: None, }; // Parse `-c` overrides. @@ -200,12 +217,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any )), }; - if oss { + if using_oss { codex_ollama::ensure_oss_ready(&config) .await .map_err(|e| anyhow::anyhow!("OSS setup failed: {e}"))?; } + if using_lmstudio { + codex_lmstudio::ensure_lmstudio_ready(&config) + .await + .map_err(|e| anyhow::anyhow!("LM Studio setup failed: {e}"))?; + } + let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); diff --git a/codex-rs/exec/tests/suite/lmstudio.rs b/codex-rs/exec/tests/suite/lmstudio.rs new file mode 100644 index 00000000000..fc1eaa6f29e --- /dev/null +++ b/codex-rs/exec/tests/suite/lmstudio.rs @@ -0,0 +1,106 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use anyhow::Context; +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; +use serde_json::Value; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::http::Method; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resolves_lmstudio_model_aliases() -> anyhow::Result<()> { + let cases = [ + ("llama", "meta-llama/Meta-Llama-3.1-8B-Instruct"), + ("qwen2", "Qwen/Qwen2-7B-Instruct"), + ("qwen3", "Qwen/Qwen3-7B-Instruct"), + ("qwen3-moe", "Qwen/Qwen3-MoE-A2.7B-Instruct"), + ]; + + for (alias, expected_model) in cases { + let test = test_codex_exec(); + let server = responses::start_mock_server().await; + + let models_payload = serde_json::json!({ + "data": [ + { "id": expected_model }, + { "id": "other/placeholder-model" } + ] + }); + + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(models_payload.clone())) + .expect(1) + .mount(&server) + .await; + + let chat_stream = concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{}}]}\n\n", + "data: [DONE]\n\n", + ); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(chat_stream, "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + test.cmd() + .env("CODEX_LM_STUDIO_BASE_URL", format!("{}/v1", server.uri())) + .arg("--skip-git-repo-check") + .arg("--backend") + .arg("lmstudio") + .arg("--model") + .arg(alias) + .arg("hi") + .assert() + .success(); + + let requests = server + .received_requests() + .await + .expect("failed to capture requests"); + let mut saw_models_check = false; + let mut resolved_model: Option = None; + + for req in &requests { + if req.method == Method::GET && req.url.path() == "/v1/models" { + saw_models_check = true; + } + + if req.method == Method::POST && req.url.path() == "/v1/chat/completions" { + let payload: Value = serde_json::from_slice(&req.body) + .context("LM Studio response request should be valid JSON")?; + resolved_model = payload + .get("model") + .and_then(Value::as_str) + .map(str::to_owned); + } + } + + assert!( + saw_models_check, + "alias `{alias}` did not trigger an LM Studio readiness probe" + ); + + let actual = resolved_model.context("LM Studio request missing model field")?; + assert_eq!( + actual, expected_model, + "alias `{alias}` should resolve to `{expected_model}`" + ); + + server.verify().await; + } + + Ok(()) +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 52f5bca34c7..79470025756 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. mod apply_patch; +mod lmstudio; mod output_schema; mod resume; mod sandbox; diff --git a/codex-rs/lmstudio/Cargo.toml b/codex-rs/lmstudio/Cargo.toml new file mode 100644 index 00000000000..bd36acf48a1 --- /dev/null +++ b/codex-rs/lmstudio/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-lmstudio" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_lmstudio" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-core = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } +wiremock = { workspace = true } diff --git a/codex-rs/lmstudio/src/lib.rs b/codex-rs/lmstudio/src/lib.rs new file mode 100644 index 00000000000..4073df817f9 --- /dev/null +++ b/codex-rs/lmstudio/src/lib.rs @@ -0,0 +1,360 @@ +use codex_core::BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID; +use codex_core::ModelProviderInfo; +use codex_core::config::Config; +use serde_json::Value as JsonValue; +use std::io; +use std::time::Duration; + +/// Default LM Studio model used when `--backend lmstudio` is specified without `--model`. +pub const DEFAULT_LM_STUDIO_MODEL: &str = "meta-llama/Meta-Llama-3.1-8B-Instruct"; + +const LM_STUDIO_CONNECTION_ERROR: &str = "No running LM Studio server detected. Launch LM Studio and enable the local inference server (Preferences → Developer → Enable local server)."; + +const SUPPORTED_ARCHITECTURES: &[&str] = &["llama", "qwen2", "qwen3", "qwen3-moe"]; + +const MODEL_ALIAS_TABLE: &[(&str, &str)] = &[ + ("llama", DEFAULT_LM_STUDIO_MODEL), + ("llama3", DEFAULT_LM_STUDIO_MODEL), + ("llama31", DEFAULT_LM_STUDIO_MODEL), + ("llama3.1", DEFAULT_LM_STUDIO_MODEL), + ("llama-3", DEFAULT_LM_STUDIO_MODEL), + ("llama-31", DEFAULT_LM_STUDIO_MODEL), + ("llama-3.1", DEFAULT_LM_STUDIO_MODEL), + ("llama3-8b", DEFAULT_LM_STUDIO_MODEL), + ("qwen2", "Qwen/Qwen2-7B-Instruct"), + ("qwen-2", "Qwen/Qwen2-7B-Instruct"), + ("qwen2-7b", "Qwen/Qwen2-7B-Instruct"), + ("qwen3", "Qwen/Qwen3-7B-Instruct"), + ("qwen-3", "Qwen/Qwen3-7B-Instruct"), + ("qwen3-7b", "Qwen/Qwen3-7B-Instruct"), + ("qwen3-moe", "Qwen/Qwen3-MoE-A2.7B-Instruct"), + ("qwen3_moe", "Qwen/Qwen3-MoE-A2.7B-Instruct"), + ("qwen-3-moe", "Qwen/Qwen3-MoE-A2.7B-Instruct"), +]; + +/// Error returned when a provided LM Studio model alias cannot be resolved. +#[derive(Debug, Clone)] +pub struct UnsupportedModelAliasError { + alias: String, +} + +impl UnsupportedModelAliasError { + fn new(alias: impl Into) -> Self { + Self { + alias: alias.into(), + } + } +} + +impl std::fmt::Display for UnsupportedModelAliasError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.alias.trim().is_empty() { + write!( + f, + "LM Studio model name cannot be empty. Supported architectures: {}. You can also pass a full LM Studio model identifier (for example `namespace/model-name`).", + SUPPORTED_ARCHITECTURES.join(", ") + ) + } else { + write!( + f, + "Unsupported LM Studio model alias `{}`. Supported architectures: {}. Provide one of the aliases or the full model identifier as shown in LM Studio.", + self.alias, + SUPPORTED_ARCHITECTURES.join(", ") + ) + } + } +} + +impl std::error::Error for UnsupportedModelAliasError {} + +/// Returns the list of LM Studio architecture aliases that Codex understands. +pub fn supported_architecture_aliases() -> &'static [&'static str] { + SUPPORTED_ARCHITECTURES +} + +/// Resolve a user-supplied model alias into the canonical LM Studio model identifier. +/// +/// When `model` is `None`, the [`DEFAULT_LM_STUDIO_MODEL`] is returned. +/// +/// Users may also pass fully-qualified model identifiers (as shown inside LM Studio); +/// these are returned unchanged. +pub fn resolve_model_identifier(model: Option<&str>) -> Result { + match model { + None => Ok(DEFAULT_LM_STUDIO_MODEL.to_string()), + Some(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(UnsupportedModelAliasError::new(trimmed)); + } + let normalized = trimmed.to_ascii_lowercase(); + if let Some((_, canonical)) = MODEL_ALIAS_TABLE + .iter() + .find(|(alias, _)| *alias == normalized) + { + return Ok((*canonical).to_string()); + } + if trimmed.contains('/') || trimmed.contains(':') { + return Ok(trimmed.to_string()); + } + Err(UnsupportedModelAliasError::new(trimmed)) + } + } +} + +/// Ensure an LM Studio instance is reachable and has the configured model available locally. +/// +/// This probes the provider's `/models` endpoint and confirms the requested model is present. +pub async fn ensure_lmstudio_ready(config: &Config) -> io::Result<()> { + let provider = config + .model_providers + .get(BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!( + "Built-in provider `{}` not found", + BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID + ), + ) + })?; + + probe_server(provider, &config.model).await +} + +async fn probe_server(provider: &ModelProviderInfo, model: &str) -> io::Result<()> { + let base_url = provider.base_url.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "LM Studio provider missing base_url", + ) + })?; + + // LM Studio exposes an OpenAI-compatible API rooted at `/v1`. + let models_url = format!("{}/models", base_url.trim_end_matches('/')); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + let response = client.get(&models_url).send().await.map_err(|err| { + tracing::warn!("Failed to connect to LM Studio server: {err:?}"); + io::Error::other(LM_STUDIO_CONNECTION_ERROR) + })?; + + if !response.status().is_success() { + tracing::warn!( + "LM Studio `/models` request failed: HTTP {}", + response.status() + ); + return Err(io::Error::other(LM_STUDIO_CONNECTION_ERROR)); + } + + let payload = response + .json::() + .await + .map_err(|err| io::Error::other(format!("Failed to parse LM Studio response: {err}")))?; + + if !model_available(&payload, model) { + return Err(io::Error::other(format!( + "LM Studio does not have a model named `{model}`. Download the requested architecture in LM Studio or pass a fully-qualified model identifier." + ))); + } + + Ok(()) +} + +fn model_available(payload: &JsonValue, target_model: &str) -> bool { + fn matches_entry(entry: &JsonValue, target: &str) -> bool { + let normalized_target = target.trim().to_ascii_lowercase(); + let short_target = target + .trim() + .rsplit('/') + .next() + .map(str::to_ascii_lowercase) + .unwrap_or_else(|| normalized_target.clone()); + + let check = |candidate: &str| { + let normalized_candidate = candidate.trim().to_ascii_lowercase(); + normalized_candidate == normalized_target + || normalized_candidate == short_target + || normalized_candidate.ends_with(&short_target) + }; + + entry + .get("id") + .and_then(|v| v.as_str()) + .map(check) + .or_else(|| entry.get("name").and_then(|v| v.as_str()).map(check)) + .or_else(|| entry.get("model").and_then(|v| v.as_str()).map(check)) + .or_else(|| entry.as_str().map(check)) + .unwrap_or(false) + } + + if let Some(entries) = payload.get("data").and_then(|v| v.as_array()) { + if entries + .iter() + .any(|entry| matches_entry(entry, target_model)) + { + return true; + } + } + + if let Some(entries) = payload.get("models").and_then(|v| v.as_array()) { + if entries + .iter() + .any(|entry| matches_entry(entry, target_model)) + { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use codex_core::config::Config; + use codex_core::config::ConfigOverrides; + use codex_core::config::ConfigToml; + + #[test] + fn resolves_aliases_to_canonical_models() { + assert_eq!( + resolve_model_identifier(Some("llama")).unwrap(), + DEFAULT_LM_STUDIO_MODEL + ); + assert_eq!( + resolve_model_identifier(Some("qwen2")).unwrap(), + "Qwen/Qwen2-7B-Instruct" + ); + assert_eq!( + resolve_model_identifier(Some("qwen3")).unwrap(), + "Qwen/Qwen3-7B-Instruct" + ); + assert_eq!( + resolve_model_identifier(Some("qwen3-moe")).unwrap(), + "Qwen/Qwen3-MoE-A2.7B-Instruct" + ); + } + + #[test] + fn returns_default_model_when_none_is_provided() { + assert_eq!( + resolve_model_identifier(None).unwrap(), + DEFAULT_LM_STUDIO_MODEL + ); + } + + #[test] + fn rejects_unknown_aliases() { + let err = resolve_model_identifier(Some("unknown")).unwrap_err(); + assert!( + err.to_string() + .contains("Supported architectures: llama, qwen2, qwen3, qwen3-moe") + ); + } + + #[tokio::test] + async fn ensure_ready_checks_for_available_model() { + let server = MockServer::start().await; + let response = serde_json::json!({ + "data": [ + { "id": DEFAULT_LM_STUDIO_MODEL }, + { "id": "Qwen/Qwen3-7B-Instruct" } + ] + }); + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .mount(&server) + .await; + + let codex_home = TempDir::new().expect("tempdir"); + let config_toml = ConfigToml::default(); + let overrides = ConfigOverrides { + model: Some(DEFAULT_LM_STUDIO_MODEL.to_string()), + model_provider: Some(BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID.to_string()), + ..ConfigOverrides::default() + }; + let mut config = Config::load_from_base_config_with_overrides( + config_toml, + overrides, + codex_home.path().to_path_buf(), + ) + .expect("load config"); + + let base_url = format!("{}/v1", server.uri()); + if let Some(provider) = config + .model_providers + .get_mut(BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID) + { + provider.base_url = Some(base_url.clone()); + } + if config + .model_provider_id + .eq(BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID) + { + config.model_provider.base_url = Some(base_url); + } + + ensure_lmstudio_ready(&config) + .await + .expect("lm studio ready"); + } + + #[tokio::test] + async fn ensure_ready_errors_when_model_missing() { + let server = MockServer::start().await; + let response = serde_json::json!({ + "data": [ { "id": "some/other-model" } ] + }); + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .mount(&server) + .await; + + let codex_home = TempDir::new().expect("tempdir"); + let config_toml = ConfigToml::default(); + let overrides = ConfigOverrides { + model: Some(DEFAULT_LM_STUDIO_MODEL.to_string()), + model_provider: Some(BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID.to_string()), + ..ConfigOverrides::default() + }; + let mut config = Config::load_from_base_config_with_overrides( + config_toml, + overrides, + codex_home.path().to_path_buf(), + ) + .expect("load config"); + + let base_url = format!("{}/v1", server.uri()); + if let Some(provider) = config + .model_providers + .get_mut(BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID) + { + provider.base_url = Some(base_url.clone()); + } + if config + .model_provider_id + .eq(BUILT_IN_LM_STUDIO_MODEL_PROVIDER_ID) + { + config.model_provider.base_url = Some(base_url); + } + + let err = ensure_lmstudio_ready(&config) + .await + .expect_err("missing model"); + assert!(err.to_string().contains("does not have a model")); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 51f5c235785..d21a5aa16c2 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -38,6 +38,7 @@ codex-file-search = { workspace = true } codex-git-tooling = { workspace = true } codex-login = { workspace = true } codex-ollama = { workspace = true } +codex-lmstudio = { workspace = true } codex-protocol = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index f0630a34c55..067061be5d7 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,5 +1,6 @@ use clap::Parser; use codex_common::ApprovalModeCliArg; +use codex_common::BackendCliArg; use codex_common::CliConfigOverrides; use std::path::PathBuf; @@ -31,10 +32,14 @@ pub struct Cli { #[arg(long, short = 'm')] pub model: Option, + /// Select the runtime backend Codex should connect to. + #[arg(long = "backend", value_enum, conflicts_with = "oss")] + pub backend: Option, + /// Convenience flag to select the local open source model provider. /// Equivalent to -c model_provider=oss; verifies a local Ollama server is /// running. - #[arg(long = "oss", default_value_t = false)] + #[arg(long = "oss", default_value_t = false, conflicts_with = "backend")] pub oss: bool, /// Configuration profile from config.toml to specify default options. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4c5e7ee97c9..7ade187916a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -5,8 +5,8 @@ #![deny(clippy::disallowed_methods)] use app::App; pub use app::AppExitInfo; +use codex_common::BackendCliArg; use codex_core::AuthManager; -use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; use codex_core::RolloutRecorder; use codex_core::config::Config; @@ -19,6 +19,7 @@ use codex_core::config::persist_model_selection; use codex_core::find_conversation_path_by_id_str; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_lmstudio::DEFAULT_LM_STUDIO_MODEL; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use codex_protocol::mcp_protocol::AuthMode; @@ -111,22 +112,37 @@ pub async fn run_main( ) }; - // 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. - let model = if let Some(model) = &cli.model { + // Determine which backend to target and derive defaults accordingly. + let backend_choice = cli.backend.unwrap_or(if cli.oss { + BackendCliArg::Oss + } else { + BackendCliArg::Openai + }); + let using_oss = backend_choice.is_oss(); + let using_lmstudio = backend_choice.is_lmstudio(); + + let mut model = if let Some(model) = &cli.model { Some(model.clone()) - } else if cli.oss { + } else if using_oss { Some(DEFAULT_OSS_MODEL.to_owned()) + } else if using_lmstudio { + Some(DEFAULT_LM_STUDIO_MODEL.to_owned()) } else { None // No model specified, will use the default. }; - let model_provider_override = if cli.oss { - Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_owned()) - } else { - None - }; + if using_lmstudio { + let resolved = match codex_lmstudio::resolve_model_identifier(model.as_deref()) { + Ok(model) => model, + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + }; + model = Some(resolved); + } + + let model_provider_override = backend_choice.provider_key().map(str::to_owned); // canonicalize the cwd let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)); @@ -144,7 +160,7 @@ pub async fn run_main( include_plan_tool: Some(true), include_apply_patch_tool: None, include_view_image_tool: None, - show_raw_agent_reasoning: cli.oss.then_some(true), + show_raw_agent_reasoning: using_oss.then_some(true), tools_web_search_request: cli.web_search.then_some(true), }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); @@ -241,12 +257,18 @@ pub async fn run_main( .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); - if cli.oss { + if using_oss { codex_ollama::ensure_oss_ready(&config) .await .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; } + if using_lmstudio { + codex_lmstudio::ensure_lmstudio_ready(&config) + .await + .map_err(|e| std::io::Error::other(format!("LM Studio setup failed: {e}")))?; + } + let _ = tracing_subscriber::registry().with(file_layer).try_init(); run_ratatui_app( @@ -540,13 +562,18 @@ fn should_show_model_rollout_prompt( gpt_5_codex_model_prompt_seen: bool, ) -> bool { let login_status = get_login_status(config); + let backend_choice = cli.backend.unwrap_or(if cli.oss { + BackendCliArg::Oss + } else { + BackendCliArg::Openai + }); active_profile.is_none() && cli.model.is_none() && !gpt_5_codex_model_prompt_seen && config.model_provider.requires_openai_auth && matches!(login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) - && !cli.oss + && !backend_choice.is_local() } #[cfg(test)] diff --git a/docs/getting-started.md b/docs/getting-started.md index e97de6a048c..8db4fc6b080 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -45,6 +45,22 @@ That's it - Codex will scaffold a file, run it inside a sandbox, install any missing dependencies, and show you the live result. Approve the changes and they'll be committed to your working directory. +### Using LM Studio as the runtime backend + +To run Codex entirely against a local LM Studio instance: + +1. Launch LM Studio, open **Preferences → Developer**, and enable the *Local Inference Server*. +2. Start the model you want to expose from LM Studio's **My Models** tab. +3. Select the backend and architecture when launching Codex: + + ```shell + codex --backend lmstudio # defaults to LLaMA 3.1 8B Instruct + codex --backend lmstudio --model qwen2 # pick a specific architecture + codex exec --backend lmstudio --model qwen3-moe "generate unit tests" + ``` + +Codex accepts friendly aliases for the most common LM Studio builds (`llama`, `qwen2`, `qwen3`, `qwen3-moe`) or you can pass the exact identifier shown in LM Studio. If the requested model is not available, Codex reports a clear error so you can download or start it inside LM Studio. + ### Example prompts Below are a few bite-size examples you can copy-paste. Replace the text in quotes with your own task.