From ab7406df66010febcf9caa39250ec57907c191e9 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Fri, 16 Oct 2026 08:33:45 -0700 Subject: [PATCH 1/7] codex-local: engine selection (--engine / CODEX_LOCAL_ENGINE / [local] engine) and /v1/models catalog + preflight Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RPCzgwU247gMtjybR5v4iR --- codex-rs/local/cli/src/lib.rs | 50 ++++++-- codex-rs/local/cli/src/main.rs | 16 ++- codex-rs/local/cli/src/overrides.rs | 167 +++++++++++++++++++++++++-- codex-rs/local/cli/src/preflight.rs | 113 +++++++++++++++++- codex-rs/local/cli/src/seed.rs | 20 +++- codex-rs/local/models/src/engine.rs | 109 +++++++++++++++++ codex-rs/local/models/src/lib.rs | 104 +++++++++++------ codex-rs/local/models/src/v1.rs | 42 +++++++ local/skills/delegate-local/SKILL.md | 6 +- 9 files changed, 556 insertions(+), 71 deletions(-) create mode 100644 codex-rs/local/models/src/engine.rs create mode 100644 codex-rs/local/models/src/v1.rs diff --git a/codex-rs/local/cli/src/lib.rs b/codex-rs/local/cli/src/lib.rs index cd0ec697f7b1..4d16e6182273 100644 --- a/codex-rs/local/cli/src/lib.rs +++ b/codex-rs/local/cli/src/lib.rs @@ -14,19 +14,21 @@ use std::path::PathBuf; use anyhow::Result; +pub use codex_local_models::Engine; pub use homes::Homes; pub use homes::resolve_homes; pub use links::ensure_home; pub use links::refresh_skills; +pub use overrides::Applied; pub use overrides::apply_overrides; pub use preflight::check as preflight_check; +pub use preflight::engine_hint; pub use preflight::failure_message as preflight_failure_message; pub use seed::seed_config; pub use sync::sync_config; pub use url::resolve_ollama_root; pub const DEFAULT_MODEL: &str = "qwen3.8:27b"; -pub const DEFAULT_OLLAMA_ROOT: &str = "http://localhost:11434"; pub const PROVIDER_ID: &str = "codex-local"; /// `+local.`; the sha comes from `build.rs`. @@ -61,6 +63,9 @@ pub enum Action { codex_home: PathBuf, /// Ollama root to probe before delegating; `None` skips the preflight. preflight_root: Option, + /// Model to look for on the other engines' default ports when the engine was + /// never chosen explicitly; `None` skips the probe. + probe_model: Option, }, } @@ -72,25 +77,50 @@ pub fn prepare(args: Vec, env: EnvFn, user_home: Option) -> R } let homes = resolve_homes(env, user_home)?; ensure_home(&homes)?; - // Seed from env/built-ins only; a one-off --ollama-url flag must not persist. - let root = env_ollama_root(env)?.unwrap_or_else(|| DEFAULT_OLLAMA_ROOT.to_string()); - seed_config(&homes, &root)?; + // Seed from env/built-ins only; a one-off --ollama-url/--engine flag must not persist. + let seed_engine = env("CODEX_LOCAL_ENGINE") + .map(|v| v.parse::().map_err(anyhow::Error::msg)) + .transpose()? + .unwrap_or_default(); + let root = env_ollama_root(env)?.unwrap_or_else(|| seed_engine.default_root()); + seed_config(&homes, &root, seed_engine)?; if first == Some("sync-config") { sync_config(&homes)?; return Ok(Action::SyncConfig); } let want_preflight = preflight::needs_preflight(&args, env); - let config_model = std::fs::read_to_string(homes.local.join("config.toml")) + let cfg = std::fs::read_to_string(homes.local.join("config.toml")) .ok() - .and_then(|t| t.parse::().ok()) + .and_then(|t| t.parse::().ok()); + let config_model = cfg + .as_ref() .and_then(|c| c.get("model").and_then(|m| m.as_str()).map(str::to_string)) .or_else(|| Some(DEFAULT_MODEL.to_string())); - let argv = apply_overrides(args, env, config_model)?; - let preflight_root = want_preflight.then(|| effective_root(&argv, &homes, &root)); + // `[local] engine = "ollama|lmstudio|llamacpp"` in codex-local's config.toml. + let config_engine = cfg + .as_ref() + .and_then(|c| c.get("local")?.get("engine")?.as_str().map(str::to_string)); + let mut applied = apply_overrides(args, env, config_model, config_engine)?; + let preflight_root = want_preflight.then(|| effective_root(&applied.argv, &homes, &root)); + // llama.cpp serves whatever it loaded: default to the first `/v1/models` id. + if let (Some(r), Engine::LlamaCpp, None) = (&preflight_root, applied.engine, &applied.model) + && !applied.argv.iter().any(|a| a == "-m" || a == "--model") + && let Some(id) = preflight::first_model_id(r) + { + applied.argv.insert(1, "-c".into()); + applied + .argv + .insert(2, format!("model={}", toml::Value::String(id)).into()); + } + let probe_model = (!applied.engine_explicit && !applied.url_explicit) + .then(|| applied.model.clone()) + .flatten() + .filter(|_| want_preflight); Ok(Action::Run { - argv, + argv: applied.argv, codex_home: homes.local, preflight_root, + probe_model, }) } @@ -118,7 +148,7 @@ fn effective_root(argv: &[OsString], homes: &Homes, fallback: &str) -> String { } pub(crate) fn env_ollama_root(env: EnvFn) -> Result> { - for key in ["CODEX_LOCAL_OLLAMA_URL", "OLLAMA_HOST"] { + for key in ["CODEX_LOCAL_URL", "CODEX_LOCAL_OLLAMA_URL", "OLLAMA_HOST"] { if let Some(v) = env(key) { return resolve_ollama_root(&v).map(Some); } diff --git a/codex-rs/local/cli/src/main.rs b/codex-rs/local/cli/src/main.rs index 16e0fbb18a29..b3598d874b76 100644 --- a/codex-rs/local/cli/src/main.rs +++ b/codex-rs/local/cli/src/main.rs @@ -51,7 +51,7 @@ fn main() -> anyhow::Result<()> { &codex_local_cli::process_env, codex_local_cli::user_home_dir(), )?; - let (argv, codex_home, preflight_root) = match action { + let (argv, codex_home, preflight_root, probe_model) = match action { codex_local_cli::Action::Version(v) => { println!("{v}"); return Ok(()); @@ -61,14 +61,20 @@ fn main() -> anyhow::Result<()> { argv, codex_home, preflight_root, - } => (argv, codex_home, preflight_root), + probe_model, + } => (argv, codex_home, preflight_root, probe_model), }; - if let Some(root) = preflight_root - && let Err(e) = codex_local_cli::preflight_check(&root) + if let Some(root) = &preflight_root + && let Err(e) = codex_local_cli::preflight_check(root) { - eprintln!("{}", codex_local_cli::preflight_failure_message(&root, &e)); + eprintln!("{}", codex_local_cli::preflight_failure_message(root, &e)); std::process::exit(1); } + if let (Some(root), Some(model)) = (&preflight_root, &probe_model) + && let Some(hint) = codex_local_cli::engine_hint(root, model) + { + eprintln!("{hint}"); + } // SAFETY: still single-threaded; no runtime or worker threads exist yet. unsafe { std::env::set_var("CODEX_HOME", &codex_home) }; codex_local_models::register(); diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index f9e0f9647553..3d6e7df4353f 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -3,6 +3,10 @@ use std::ffi::OsString; use anyhow::Result; use anyhow::bail; +use codex_local_models::Engine; +use codex_local_models::resolve_engine; + +use crate::DEFAULT_MODEL; use crate::EnvFn; use crate::PROVIDER_ID; use crate::env_ollama_root; @@ -10,11 +14,27 @@ use crate::resolve_ollama_root; pub(crate) const MAX_RETRIES: u64 = 2; -/// Strip `--ollama-url` (ours, unknown to upstream) and inject `-c` overrides right +/// Result of [`apply_overrides`]. +#[derive(Debug)] +pub struct Applied { + pub argv: Vec, + pub engine: Engine, + /// Flag, env or config set the engine (else it is the `ollama` default). + pub engine_explicit: bool, + /// A URL flag or env var chose the root. + pub url_explicit: bool, + /// The model we injected (`None` when `-m`/`-c model=` was given, or llama.cpp + /// still needs its first `/v1/models` id). + pub model: Option, +} + +/// Strip `--engine`, `--base-url`/`--ollama-url` (ours, unknown to upstream) and inject `-c` overrides right /// after argv[0] (root-level, so they reach every subcommand). /// -/// Precedence: flags (`-m`, `--ollama-url`) > `CODEX_LOCAL_MODEL` > -/// `CODEX_LOCAL_OLLAMA_URL` > `OLLAMA_HOST` > config.toml > built-ins. `-m` is left to +/// Precedence: flags (`-m`, `--base-url`/`--ollama-url`) > `CODEX_LOCAL_MODEL` > +/// `CODEX_LOCAL_URL` > `CODEX_LOCAL_OLLAMA_URL` > `OLLAMA_HOST` > config.toml > built-ins. +/// The engine is `--engine` > `CODEX_LOCAL_ENGINE` > config `[local] engine` > `ollama`; +/// a non-Ollama engine with no URL override gets its default port and default model. `-m` is left to /// upstream (it already wins), so the env model is injected only when no model flag is /// present, else the configured model. Both the provider and the model are always /// injected: upstream's TUI only overrides a resumed session's rollout provider/model @@ -24,9 +44,11 @@ pub fn apply_overrides( args: Vec, env: EnvFn, config_model: Option, -) -> Result> { + config_engine: Option, +) -> Result { let mut rest = Vec::with_capacity(args.len()); let mut flag_url: Option = None; + let mut flag_engine: Option = None; let mut has_model_flag = false; let mut it = args.into_iter(); let argv0 = it.next(); @@ -39,15 +61,27 @@ pub fn apply_overrides( let s = a.to_string_lossy().into_owned(); if s == "--" { after_dd = true; - } else if s == "--ollama-url" { + } else if s == "--ollama-url" || s == "--base-url" { match it.next() { Some(v) => flag_url = Some(v.to_string_lossy().into_owned()), - None => bail!("--ollama-url requires a value"), + None => bail!("{s} requires a value"), } continue; - } else if let Some(v) = s.strip_prefix("--ollama-url=") { + } else if let Some(v) = s + .strip_prefix("--ollama-url=") + .or_else(|| s.strip_prefix("--base-url=")) + { flag_url = Some(v.to_string()); continue; + } else if s == "--engine" { + match it.next() { + Some(v) => flag_engine = Some(v.to_string_lossy().into_owned()), + None => bail!("--engine requires a value"), + } + continue; + } else if let Some(v) = s.strip_prefix("--engine=") { + flag_engine = Some(v.to_string()); + continue; } else if s == "-m" || s == "--model" || s.starts_with("--model=") || s.starts_with("-m=") { has_model_flag = true; } else if s.starts_with("model=") && rest.last().is_some_and(|p| p == "-c") { @@ -56,18 +90,36 @@ pub fn apply_overrides( rest.push(a); } + let (engine, engine_explicit) = resolve_engine( + flag_engine.as_deref(), + env("CODEX_LOCAL_ENGINE").as_deref(), + config_engine.as_deref(), + ) + .map_err(anyhow::Error::msg)?; let root = match flag_url { Some(u) => Some(resolve_ollama_root(&u)?), None => env_ollama_root(env)?, }; + let url_explicit = root.is_some(); + // Seeded config points at Ollama; another engine needs its own default root. + let root = root.or_else(|| (engine != Engine::Ollama).then(|| engine.default_root())); + // The seeded Ollama default model is wrong for another engine. + let config_model = config_model.filter(|m| engine == Engine::Ollama || m != DEFAULT_MODEL); + let model = if has_model_flag { + None + } else { + env("CODEX_LOCAL_MODEL") + .or(config_model) + .or_else(|| engine.default_model().map(str::to_string)) + }; let mut out: Vec = argv0.into_iter().collect(); let mut push_c = |kv: String| { out.push("-c".into()); out.push(kv.into()); }; push_c(format!("model_provider=\"{PROVIDER_ID}\"")); - if !has_model_flag && let Some(m) = env("CODEX_LOCAL_MODEL").or(config_model) { - push_c(format!("model={}", toml::Value::String(m))); + if let Some(m) = &model { + push_c(format!("model={}", toml::Value::String(m.clone()))); } if let Some(root) = root { let v = toml::Value::String(format!("{root}/v1")); @@ -93,7 +145,13 @@ pub fn apply_overrides( } } out.extend(rest); - Ok(out) + Ok(Applied { + argv: out, + engine, + engine_explicit, + url_explicit, + model, + }) } #[cfg(test)] @@ -107,8 +165,9 @@ mod tests { .map(|(_, v)| v.to_string()) }; let a = args.iter().map(OsString::from).collect(); - apply_overrides(a, &f, Some("cfgm".to_string())) + apply_overrides(a, &f, Some("cfgm".to_string()), None) .unwrap() + .argv .iter() .map(|s| s.to_string_lossy().into_owned()) .collect() @@ -122,6 +181,92 @@ mod tests { const DA: &str = "features.daemon_auto_start=false"; const URL_KEY: &str = "model_providers.codex-local.base_url"; + fn applied(args: &[&str], env: &[(&str, &str)], cfg_engine: Option<&str>) -> Applied { + let f = |k: &str| { + env.iter() + .find(|(n, _)| *n == k) + .map(|(_, v)| v.to_string()) + }; + let a = args.iter().map(OsString::from).collect(); + apply_overrides( + a, + &f, + Some(DEFAULT_MODEL.to_string()), + cfg_engine.map(str::to_string), + ) + .unwrap() + } + + fn has(a: &Applied, kv: &str) -> bool { + a.argv.iter().any(|s| s == kv) + } + + #[test] + fn engine_precedence_flag_env_config_default() { + let a = applied(&["x", "exec"], &[], None); + assert_eq!((a.engine, a.engine_explicit), (Engine::Ollama, false)); + assert!( + !a.argv + .iter() + .any(|s| s.to_string_lossy().contains("base_url")) + ); + let a = applied(&["x", "exec"], &[], Some("lmstudio")); + assert_eq!((a.engine, a.engine_explicit), (Engine::LmStudio, true)); + let a = applied( + &["x", "exec"], + &[("CODEX_LOCAL_ENGINE", "llamacpp")], + Some("lmstudio"), + ); + assert_eq!(a.engine, Engine::LlamaCpp); + let a = applied( + &["x", "--engine", "ollama", "exec"], + &[("CODEX_LOCAL_ENGINE", "llamacpp")], + Some("lmstudio"), + ); + assert_eq!(a.engine, Engine::Ollama); + assert!(!a.argv.iter().any(|s| s == "--engine" || s == "ollama")); + assert!( + apply_overrides( + vec!["x".into(), "--engine=bogus".into()], + &|_| None, + None, + None + ) + .is_err() + ); + } + + #[test] + fn non_ollama_engine_gets_port_and_model_unless_overridden() { + let a = applied(&["x", "--engine=lmstudio", "exec"], &[], None); + assert!(has( + &a, + "model_providers.codex-local.base_url=\"http://localhost:1234/v1\"" + )); + assert!(has(&a, "model=\"qwen3.8-27b-mlx\"")); + assert!(!a.url_explicit); + let a = applied( + &["x", "--engine=lmstudio", "--base-url", "h:9", "exec"], + &[], + None, + ); + assert!(has( + &a, + "model_providers.codex-local.base_url=\"http://h:9/v1\"" + )); + assert!(a.url_explicit); + let a = applied( + &["x", "--engine=llamacpp", "exec"], + &[("CODEX_LOCAL_URL", "h:8")], + None, + ); + assert!(has( + &a, + "model_providers.codex-local.base_url=\"http://h:8/v1\"" + )); + assert_eq!(a.model, None); + } + #[test] fn nothing_set_changes_nothing() { let want = [ diff --git a/codex-rs/local/cli/src/preflight.rs b/codex-rs/local/cli/src/preflight.rs index b1d10f53d61d..813ab4b66fff 100644 --- a/codex-rs/local/cli/src/preflight.rs +++ b/codex-rs/local/cli/src/preflight.rs @@ -1,8 +1,12 @@ -//! Fail-fast check that Ollama answers before upstream starts its retry loops. +//! Fail-fast check that the local engine answers before upstream starts its retry loops. use std::ffi::OsString; use std::time::Duration; +use codex_local_models::Engine; +use codex_local_models::V1Model; +use codex_local_models::list_v1_models; + use crate::EnvFn; const TIMEOUT: Duration = Duration::from_secs(3); @@ -57,6 +61,8 @@ const VALUE_FLAGS: &[&str] = &[ "-a", "--ask-for-approval", "--ollama-url", + "--base-url", + "--engine", "--local-provider", "--enable", "--disable", @@ -84,7 +90,7 @@ pub(crate) fn needs_preflight(args: &[OsString], env: EnvFn) -> bool { true } -/// `GET /api/tags`; the error is a short human string. +/// `GET /v1/models`; the error is a short human string. pub fn check(root: &str) -> Result<(), String> { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -92,7 +98,7 @@ pub fn check(root: &str) -> Result<(), String> { .map_err(|e| e.to_string())?; rt.block_on(async { let r = reqwest::Client::new() - .get(format!("{root}/api/tags")) + .get(format!("{root}/v1/models")) .timeout(TIMEOUT) .send() .await @@ -113,11 +119,67 @@ pub fn check(root: &str) -> Result<(), String> { pub fn failure_message(root: &str, err: &str) -> String { format!( - "codex-local: can't reach Ollama at {root} ({err}). Is it running? \ - Set --ollama-url or CODEX_LOCAL_OLLAMA_URL." + "codex-local: can't reach the local model server at {root} ({err}). Is it running? \ + Set --engine, --base-url or CODEX_LOCAL_URL." ) } +fn block_on(f: impl std::future::Future) -> Option { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok() + .map(|rt| rt.block_on(f)) +} + +fn list(root: &str, timeout: Duration) -> Result, String> { + block_on(list_v1_models(&reqwest::Client::new(), root, timeout)) + .unwrap_or_else(|| Err("no runtime".into())) +} + +/// First `/v1/models` id (llama.cpp's default model). +pub(crate) fn first_model_id(root: &str) -> Option { + list(root, TIMEOUT).ok()?.into_iter().next().map(|m| m.id) +} + +/// First candidate engine whose root lists `model`; each probe gets `timeout`. +pub(crate) fn find_model_elsewhere( + model: &str, + candidates: &[(Engine, String)], + timeout: Duration, +) -> Option { + candidates.iter().find_map(|(e, root)| { + list(root, timeout) + .ok()? + .iter() + .any(|m| m.id == model) + .then_some(*e) + }) +} + +/// Engine never chosen explicitly: if `root` answers but lacks `model`, probe the other +/// two engines' default ports (1 s each) and return a hint to print. +pub fn engine_hint(root: &str, model: &str) -> Option { + let others: Vec<_> = Engine::ALL + .into_iter() + .filter(|e| *e != Engine::Ollama) + .map(|e| (e, format!("http://127.0.0.1:{}", e.default_port()))) + .collect(); + hint_with(root, model, &others) +} + +fn hint_with(root: &str, model: &str, others: &[(Engine, String)]) -> Option { + let here = list(root, TIMEOUT).ok()?; + if here.iter().any(|m| m.id == model) { + return None; + } + let e = find_model_elsewhere(model, others, Duration::from_secs(1))?; + Some(format!( + "codex-local: model `{model}` not found at {root}, but {e} serves it; \ + use --engine {e} or set `[local] engine = \"{e}\"` in config.toml." + )) +} + #[cfg(test)] mod tests { use super::*; @@ -148,4 +210,45 @@ mod tests { let e = check("http://127.0.0.1:1").unwrap_err(); assert!(t.elapsed() < Duration::from_secs(4), "{e}"); } + + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + async fn serve(ids: &[&str]) -> MockServer { + let s = MockServer::start().await; + let data: Vec<_> = ids.iter().map(|i| serde_json::json!({"id": i})).collect(); + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": data})), + ) + .mount(&s) + .await; + s + } + + #[tokio::test(flavor = "multi_thread")] + async fn preflight_uses_v1_models_and_two_port_probe_finds_engine() { + let (here, lm, llama) = ( + serve(&["a"]).await, + serve(&["m"]).await, + serve(&["z"]).await, + ); + let (h, l, p) = (here.uri(), lm.uri(), llama.uri()); + let out = tokio::task::spawn_blocking(move || { + assert!(check(&h).is_ok()); + assert_eq!(first_model_id(&p).as_deref(), Some("z")); + let others = [(Engine::LmStudio, l), (Engine::LlamaCpp, p)]; + let found = hint_with(&h, "m", &others); + assert!(hint_with(&h, "a", &others).is_none()); + assert!(hint_with(&h, "nope", &others).is_none()); + found + }) + .await + .unwrap(); + assert!(out.unwrap().contains("--engine lmstudio")); + } } diff --git a/codex-rs/local/cli/src/seed.rs b/codex-rs/local/cli/src/seed.rs index d646e9147226..ddb2594ac9c6 100644 --- a/codex-rs/local/cli/src/seed.rs +++ b/codex-rs/local/cli/src/seed.rs @@ -5,6 +5,8 @@ use anyhow::Result; use toml::Table; use toml::Value; +use codex_local_models::Engine; + use crate::DEFAULT_MODEL; use crate::Homes; use crate::PROVIDER_ID; @@ -33,7 +35,7 @@ pub(crate) fn web_search_server() -> Value { ]) } -pub(crate) fn seed_toml(root: &str, stock_home: &str) -> Result { +pub(crate) fn seed_toml(root: &str, stock_home: &str, engine: Engine) -> Result { let s = |v: &str| Value::String(v.to_string()); let provider = table([ ("name", s("codex-local")), @@ -68,7 +70,10 @@ pub(crate) fn seed_toml(root: &str, stock_home: &str) -> Result { "mcp_servers".into(), table([(WEB_SEARCH_ID, web_search_server())]), ); - t.insert("agents".into(), table([("max_threads", Value::Integer(6))])); + t.insert( + "agents".into(), + table([("max_threads", Value::Integer(engine.default_max_threads()))]), + ); t.insert( "shell_environment_policy".into(), table([("set", table([("CODEX_HOME", s(stock_home))]))]), @@ -77,21 +82,23 @@ pub(crate) fn seed_toml(root: &str, stock_home: &str) -> Result { } /// Write codex-local's `config.toml` if absent. Returns whether it was written. -pub fn seed_config(h: &Homes, root: &str) -> Result { +pub fn seed_config(h: &Homes, root: &str, engine: Engine) -> Result { let path = h.local.join("config.toml"); if path.exists() { return Ok(false); } - let body = seed_toml(root, &h.stock.to_string_lossy())?; + let body = seed_toml(root, &h.stock.to_string_lossy(), engine)?; fs::write(&path, body).with_context(|| format!("write {}", path.display()))?; Ok(true) } #[cfg(test)] mod tests { + use codex_local_models::Engine; + #[test] fn seeds_six_agent_threads() { - let body = super::seed_toml("http://127.0.0.1:11434", "/h").unwrap(); + let body = super::seed_toml("http://127.0.0.1:11434", "/h", Engine::Ollama).unwrap(); let v: toml::Value = toml::from_str(&body).unwrap(); assert_eq!(v["agents"]["max_threads"].as_integer(), Some(6)); assert_eq!(v["web_search"].as_str(), Some("disabled")); @@ -100,5 +107,8 @@ mod tests { assert_eq!(ws["command"].as_str(), Some("uvx")); assert_eq!(ws["default_tools_approval_mode"].as_str(), Some("approve")); assert_eq!(ws["args"][0].as_str(), Some(super::WEB_SEARCH_PKG)); + let lm = super::seed_toml("http://localhost:1234", "/h", Engine::LmStudio).unwrap(); + let lm: toml::Value = toml::from_str(&lm).unwrap(); + assert_eq!(lm["agents"]["max_threads"].as_integer(), Some(7)); } } diff --git a/codex-rs/local/models/src/engine.rs b/codex-rs/local/models/src/engine.rs new file mode 100644 index 000000000000..7689612e1a97 --- /dev/null +++ b/codex-rs/local/models/src/engine.rs @@ -0,0 +1,109 @@ +//! Which local inference engine codex-local talks to. + +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum Engine { + #[default] + Ollama, + LmStudio, + LlamaCpp, +} + +impl Engine { + pub const ALL: [Engine; 3] = [Engine::Ollama, Engine::LmStudio, Engine::LlamaCpp]; + + pub fn as_str(self) -> &'static str { + match self { + Engine::Ollama => "ollama", + Engine::LmStudio => "lmstudio", + Engine::LlamaCpp => "llamacpp", + } + } + + pub fn default_port(self) -> u16 { + match self { + Engine::Ollama => 11434, + Engine::LmStudio => 1234, + Engine::LlamaCpp => 8080, + } + } + + pub fn default_root(self) -> String { + format!("http://localhost:{}", self.default_port()) + } + + /// `None` for llama.cpp: it serves whatever it loaded (first `/v1/models` id). + pub fn default_model(self) -> Option<&'static str> { + match self { + Engine::Ollama => Some("qwen3.8:27b"), + Engine::LmStudio => Some("qwen3.8-27b-mlx"), + Engine::LlamaCpp => None, + } + } + + /// Seeded `[agents] max_threads`. + pub fn default_max_threads(self) -> i64 { + if self == Engine::LmStudio { 7 } else { 6 } + } +} + +impl fmt::Display for Engine { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Engine { + type Err = String; + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "ollama" => Ok(Engine::Ollama), + "lmstudio" | "lm-studio" => Ok(Engine::LmStudio), + "llamacpp" | "llama.cpp" | "llama-cpp" => Ok(Engine::LlamaCpp), + other => Err(format!( + "unknown engine `{other}` (expected ollama, lmstudio or llamacpp)" + )), + } + } +} + +/// Precedence: flag > env > config > default (`Ollama`). The bool is whether any +/// source set it explicitly. +pub fn resolve_engine( + flag: Option<&str>, + env: Option<&str>, + config: Option<&str>, +) -> Result<(Engine, bool), String> { + match flag.or(env).or(config) { + Some(v) => v.parse().map(|e| (e, true)), + None => Ok((Engine::default(), false)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_and_precedence() { + assert_eq!("LMStudio".parse(), Ok(Engine::LmStudio)); + assert!("vllm".parse::().is_err()); + let r = resolve_engine; + assert_eq!(r(None, None, None), Ok((Engine::Ollama, false))); + assert_eq!( + r(None, None, Some("lmstudio")), + Ok((Engine::LmStudio, true)) + ); + assert_eq!( + r(None, Some("llamacpp"), Some("lmstudio")), + Ok((Engine::LlamaCpp, true)) + ); + assert_eq!( + r(Some("ollama"), Some("llamacpp"), Some("lmstudio")), + Ok((Engine::Ollama, true)) + ); + assert!(r(None, None, Some("bogus")).is_err()); + } +} diff --git a/codex-rs/local/models/src/lib.rs b/codex-rs/local/models/src/lib.rs index 98c963651b7a..b97b07b8b4d8 100644 --- a/codex-rs/local/models/src/lib.rs +++ b/codex-rs/local/models/src/lib.rs @@ -1,7 +1,15 @@ //! Ollama-backed model catalog for `codex-local` (`/model` picker, req 8). //! -//! Lists models via `GET /api/tags` (PAIR merges the cluster's models) and reads each -//! context window via `POST /api/show`. +//! Lists models via `GET /v1/models` (every engine serves it). The context window comes +//! from the entry's `context_length`, else (Ollama-shaped bodies only) `POST /api/show`. + +mod engine; +mod v1; + +pub use engine::Engine; +pub use engine::resolve_engine; +pub use v1::V1Model; +pub use v1::list_v1_models; use std::fmt; use std::sync::Arc; @@ -33,8 +41,8 @@ use serde_json::Value; use tokio::sync::TryLockError; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -/// Matches upstream's fallback metadata when `/api/show` gives no context length. -const FALLBACK_CONTEXT_WINDOW: i64 = 272_000; +/// Used when neither `/v1/models` nor `/api/show` gives a context length. +const FALLBACK_CONTEXT_WINDOW: i64 = 32_768; #[derive(Debug)] pub struct OllamaModelsEndpoint { @@ -47,7 +55,7 @@ pub struct OllamaModelsEndpoint { fn warn_once(e: &dyn fmt::Display) { static LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); if !LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { - tracing::warn!("codex-local: Ollama model list unavailable: {e}"); + tracing::warn!("codex-local: model list unavailable: {e}"); } } @@ -81,40 +89,28 @@ impl OllamaModelsEndpoint { } async fn fetch(&self) -> CoreResult> { - let invalid = |e: &dyn fmt::Display| CodexErr::InvalidRequest(format!("ollama: {e}")); - let body: Value = self - .client - .get(format!("{}/api/tags", self.root)) - .timeout(REQUEST_TIMEOUT) - .send() + let invalid = |e: &dyn fmt::Display| CodexErr::InvalidRequest(format!("local engine: {e}")); + let listed = list_v1_models(&self.client, &self.root, REQUEST_TIMEOUT) .await - .and_then(reqwest::Response::error_for_status) .map_err(|e| { warn_once(&e); invalid(&e) - })? - .json() - .await - .map_err(|e| invalid(&e))?; - let mut names: Vec = Vec::new(); - for m in body["models"].as_array().into_iter().flatten() { - if let Some(n) = m["name"].as_str().or_else(|| m["model"].as_str()) - && !names.iter().any(|x| x == n) - { - names.push(n.to_string()); - } - } + })?; let mut set = tokio::task::JoinSet::new(); - for (i, n) in names.iter().enumerate() { - let (this, n) = (self.clone_light(), n.clone()); - set.spawn(async move { (i, this.context_window(&n).await) }); + for (i, m) in listed.iter().enumerate() { + // Only Ollama-shaped answers need `/api/show`; others report the length inline. + if m.context_length.is_none() { + let (this, n) = (self.clone_light(), m.id.clone()); + set.spawn(async move { (i, this.context_window(&n).await) }); + } } - let mut windows = vec![None; names.len()]; + let mut windows: Vec> = listed.iter().map(|m| m.context_length).collect(); while let Some(Ok((i, w))) = set.join_next().await { windows[i] = w; } - Ok(names + Ok(listed .into_iter() + .map(|m| m.id) .zip(windows) .enumerate() .map(|(i, (name, w))| self.model_info(&name, w, i)) @@ -300,9 +296,10 @@ mod tests { async fn lists_tags_with_context_windows() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/api/tags")) + .and(path("/v1/models")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "models": [{"name": "a:1"}, {"name": "b:2"}, {"name": "c:3"}, {"name": "a:1"}] + "object": "list", + "data": [{"id": "a:1"}, {"id": "b:2"}, {"id": "c:3"}, {"id": "a:1"}] }))) .mount(&server) .await; @@ -330,9 +327,9 @@ mod tests { async fn catalog_is_ollama_only_without_bundled_models() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/api/tags")) + .and(path("/v1/models")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "models": [{"name": "a:1"}, {"name": "b:2"}, {"name": "c:3"}] + "data": [{"id": "a:1"}, {"id": "b:2"}, {"id": "c:3"}] }))) .mount(&server) .await; @@ -355,4 +352,45 @@ mod tests { assert_eq!(ids, vec!["a:1", "b:2", "c:3"]); assert!(ids.iter().all(|i| !i.starts_with("gpt-"))); } + + /// LM Studio / PAIR shape: `/v1/models` only (with `context_length`); an LM Studio-shaped + /// `/api/tags` is ignored and `/api/show` is never called. + #[tokio::test] + async fn v1_models_only_uses_context_length_and_ignores_api_tags() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{"id": "m1", "context_length": 65536}, {"id": "m2"}] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/tags")) + .respond_with( + ResponseTemplate::new(200).set_body_json( + serde_json::json!({"models": [{"key": "bogus", "type": "llm"}]}), + ), + ) + .expect(0) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/show")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + let got = OllamaModelsEndpoint::new(server.uri(), false) + .fetch() + .await + .unwrap(); + let got: Vec<_> = got + .iter() + .map(|m| (m.slug.as_str(), m.context_window)) + .collect(); + assert_eq!( + got, + vec![("m1", Some(65536)), ("m2", Some(FALLBACK_CONTEXT_WINDOW))] + ); + } } diff --git a/codex-rs/local/models/src/v1.rs b/codex-rs/local/models/src/v1.rs new file mode 100644 index 000000000000..dff0b7d3e31d --- /dev/null +++ b/codex-rs/local/models/src/v1.rs @@ -0,0 +1,42 @@ +//! `GET /v1/models`, the one catalog endpoint every engine serves. + +use std::time::Duration; + +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V1Model { + pub id: String, + /// Per-slot context window when the engine reports it (PAIR/LM Studio facade). + pub context_length: Option, +} + +/// `root` has no `/v1`. Errors are short human strings. +pub async fn list_v1_models( + client: &reqwest::Client, + root: &str, + timeout: Duration, +) -> Result, String> { + let body: Value = client + .get(format!("{}/v1/models", root.trim_end_matches('/'))) + .timeout(timeout) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|e| e.without_url().to_string())? + .json() + .await + .map_err(|e| e.without_url().to_string())?; + let mut out: Vec = Vec::new(); + for m in body["data"].as_array().into_iter().flatten() { + if let Some(id) = m["id"].as_str() + && !out.iter().any(|x| x.id == id) + { + out.push(V1Model { + id: id.to_string(), + context_length: m["context_length"].as_i64().filter(|n| *n > 0), + }); + } + } + Ok(out) +} diff --git a/local/skills/delegate-local/SKILL.md b/local/skills/delegate-local/SKILL.md index 28f2be2b2442..403309e857d5 100644 --- a/local/skills/delegate-local/SKILL.md +++ b/local/skills/delegate-local/SKILL.md @@ -21,8 +21,10 @@ reliable ONLY on narrow, fully-specified, single-shot units paired with a cheap `codex-local` is installed beside stock `codex` (`local/scripts/install.py`). It defaults to model `qwen3.8:27b` on `http://localhost:11434` (plain Ollama or a PAIR proxy). Overrides: -`-m MODEL`, `--ollama-url URL`, env `CODEX_LOCAL_MODEL` / `CODEX_LOCAL_OLLAMA_URL` / -`OLLAMA_HOST`. No profiles, tunnels, fleet files or node locks. Check with +`-m MODEL`, `--base-url URL` (alias `--ollama-url`), `--engine ollama|lmstudio|llamacpp`, env +`CODEX_LOCAL_MODEL` / `CODEX_LOCAL_URL` / `CODEX_LOCAL_OLLAMA_URL` / `OLLAMA_HOST` / +`CODEX_LOCAL_ENGINE`. The engine can also be set in codex-local's config.toml +(`[local]` table, `engine = "lmstudio"`); precedence is flag > env > config > `ollama`. No profiles, tunnels, fleet files or node locks. Check with `verify_install.py` (full) if a unit fails oddly. ## Green / Yellow / Red decisioning From 736e172b8a664acb2e11f1a4b4d071aea449375d Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Fri, 16 Oct 2026 08:40:56 -0700 Subject: [PATCH 2/7] Fix engine review findings: config context wins, engine-first URL resolution, fork-owned codex-local.toml Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RPCzgwU247gMtjybR5v4iR --- codex-rs/local/cli/src/lib.rs | 144 +++++++++++++++++++++++----- codex-rs/local/cli/src/local_cfg.rs | 55 +++++++++++ codex-rs/local/cli/src/main.rs | 22 +++-- codex-rs/local/cli/src/overrides.rs | 55 +++++++++-- codex-rs/local/cli/src/preflight.rs | 10 +- codex-rs/local/models/src/lib.rs | 6 +- codex-rs/local/models/src/v1.rs | 6 ++ 7 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 codex-rs/local/cli/src/local_cfg.rs diff --git a/codex-rs/local/cli/src/lib.rs b/codex-rs/local/cli/src/lib.rs index 4d16e6182273..accefb976f4b 100644 --- a/codex-rs/local/cli/src/lib.rs +++ b/codex-rs/local/cli/src/lib.rs @@ -3,6 +3,7 @@ mod homes; mod links; +mod local_cfg; mod overrides; mod preflight; mod seed; @@ -78,16 +79,19 @@ pub fn prepare(args: Vec, env: EnvFn, user_home: Option) -> R let homes = resolve_homes(env, user_home)?; ensure_home(&homes)?; // Seed from env/built-ins only; a one-off --ollama-url/--engine flag must not persist. + // The seeded URL is always Ollama's (an engine's default port is injected at run time, + // after the winning engine is known), and a bad env engine must not abort here. let seed_engine = env("CODEX_LOCAL_ENGINE") - .map(|v| v.parse::().map_err(anyhow::Error::msg)) - .transpose()? + .and_then(|v| v.parse::().ok()) .unwrap_or_default(); - let root = env_ollama_root(env)?.unwrap_or_else(|| seed_engine.default_root()); + let root = env_ollama_root(env)?.unwrap_or_else(|| Engine::Ollama.default_root()); seed_config(&homes, &root, seed_engine)?; + let config_engine = local_cfg::engine(&homes)?; if first == Some("sync-config") { sync_config(&homes)?; return Ok(Action::SyncConfig); } + let args_snapshot = args.clone(); let want_preflight = preflight::needs_preflight(&args, env); let cfg = std::fs::read_to_string(homes.local.join("config.toml")) .ok() @@ -96,24 +100,27 @@ pub fn prepare(args: Vec, env: EnvFn, user_home: Option) -> R .as_ref() .and_then(|c| c.get("model").and_then(|m| m.as_str()).map(str::to_string)) .or_else(|| Some(DEFAULT_MODEL.to_string())); - // `[local] engine = "ollama|lmstudio|llamacpp"` in codex-local's config.toml. - let config_engine = cfg - .as_ref() - .and_then(|c| c.get("local")?.get("engine")?.as_str().map(str::to_string)); - let mut applied = apply_overrides(args, env, config_model, config_engine)?; + let config_root = config_root(cfg.as_ref()); + let mut applied = apply_overrides(args, env, config_model, config_engine, config_root)?; let preflight_root = want_preflight.then(|| effective_root(&applied.argv, &homes, &root)); - // llama.cpp serves whatever it loaded: default to the first `/v1/models` id. - if let (Some(r), Engine::LlamaCpp, None) = (&preflight_root, applied.engine, &applied.model) - && !applied.argv.iter().any(|a| a == "-m" || a == "--model") - && let Some(id) = preflight::first_model_id(r) + // llama.cpp serves whatever it loaded: default to the first `/v1/models` id. This is + // model selection, so it runs even when the preflight itself is skipped. + if applied.engine == Engine::LlamaCpp + && applied.effective_model.is_none() + && preflight::uses_model(&args_snapshot) { - applied.argv.insert(1, "-c".into()); - applied - .argv - .insert(2, format!("model={}", toml::Value::String(id)).into()); + let r = preflight_root + .clone() + .unwrap_or_else(|| effective_root(&applied.argv, &homes, &root)); + if let Some(id) = preflight::first_model_id(&r) { + applied.argv.insert(1, "-c".into()); + applied + .argv + .insert(2, format!("model={}", toml::Value::String(id)).into()); + } } let probe_model = (!applied.engine_explicit && !applied.url_explicit) - .then(|| applied.model.clone()) + .then(|| applied.effective_model.clone()) .flatten() .filter(|_| want_preflight); Ok(Action::Run { @@ -136,17 +143,30 @@ fn effective_root(argv: &[OsString], homes: &Homes, fallback: &str) -> String { return strip(v.trim_matches('"')); } } - let cfg = std::fs::read_to_string(homes.local.join("config.toml")) - .ok() - .and_then(|t| t.parse::().ok()); - cfg.and_then(|c| { - c["model_providers"][PROVIDER_ID]["base_url"] - .as_str() - .map(strip) - }) + config_root( + std::fs::read_to_string(homes.local.join("config.toml")) + .ok() + .and_then(|t| t.parse::().ok()) + .as_ref(), + ) .unwrap_or_else(|| fallback.to_string()) } +/// Provider root from config.toml, unless it is just the seeded Ollama default (which +/// must not beat another engine's default port). +fn config_root(cfg: Option<&toml::Table>) -> Option { + let url = cfg? + .get("model_providers")? + .get(PROVIDER_ID)? + .get("base_url")? + .as_str()?; + let root = url + .trim_end_matches('/') + .trim_end_matches("/v1") + .to_string(); + (root != Engine::Ollama.default_root()).then_some(root) +} + pub(crate) fn env_ollama_root(env: EnvFn) -> Result> { for key in ["CODEX_LOCAL_URL", "CODEX_LOCAL_OLLAMA_URL", "OLLAMA_HOST"] { if let Some(v) = env(key) { @@ -246,4 +266,78 @@ mod tests { 1 ); } + + fn run_env(tmp: &std::path::Path, extra: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let mut m: Vec<(String, String)> = vec![ + ( + "CODEX_HOME".into(), + tmp.join("stock").to_string_lossy().into(), + ), + ( + "CODEX_LOCAL_HOME".into(), + tmp.join("local").to_string_lossy().into(), + ), + ]; + m.extend(extra.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + move |k: &str| m.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()) + } + + fn base_url_arg(a: &Action) -> Option { + let Action::Run { argv, .. } = a else { + panic!() + }; + argv.iter() + .map(|s| s.to_string_lossy().into_owned()) + .find(|s| s.contains(".base_url=")) + } + + #[test] + fn flag_engine_beats_bad_env_engine_and_seed_never_bakes_a_port() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("stock")).unwrap(); + let env = run_env( + tmp.path(), + &[ + ("CODEX_LOCAL_ENGINE", "bogus"), + ("CODEX_LOCAL_SKIP_PREFLIGHT", "1"), + ], + ); + let a = |v: &[&str]| v.iter().map(OsString::from).collect::>(); + let act = prepare(a(&["x", "--engine", "ollama", "exec"]), &env, None).unwrap(); + assert_eq!(base_url_arg(&act), None); + let env = run_env( + tmp.path(), + &[ + ("CODEX_LOCAL_ENGINE", "lmstudio"), + ("CODEX_LOCAL_SKIP_PREFLIGHT", "1"), + ], + ); + let cfg = fs::read_to_string(tmp.path().join("local/config.toml")).unwrap(); + assert!(cfg.contains("localhost:11434/v1"), "{cfg}"); + let act = prepare(a(&["x", "exec"]), &env, None).unwrap(); + assert!(base_url_arg(&act).unwrap().contains(":1234/v1")); + } + + #[test] + fn legacy_local_table_migrates_to_codex_local_toml() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("stock")).unwrap(); + let env = run_env(tmp.path(), &[("CODEX_LOCAL_SKIP_PREFLIGHT", "1")]); + let a = |v: &[&str]| v.iter().map(OsString::from).collect::>(); + prepare(a(&["x", "exec"]), &env, None).unwrap(); + let own = fs::read_to_string(tmp.path().join("local/codex-local.toml")).unwrap(); + assert!(!own.contains("\nengine"), "seeded unset: {own}"); + let cfg_path = tmp.path().join("local/config.toml"); + let cfg = fs::read_to_string(&cfg_path).unwrap(); + fs::write( + &cfg_path, + format!("{cfg}\n[local]\nengine = \"lmstudio\"\n"), + ) + .unwrap(); + let act = prepare(a(&["x", "exec"]), &env, None).unwrap(); + assert!(base_url_arg(&act).unwrap().contains(":1234/v1")); + assert!(!fs::read_to_string(&cfg_path).unwrap().contains("[local]")); + let own = fs::read_to_string(tmp.path().join("local/codex-local.toml")).unwrap(); + assert!(own.contains("engine = \"lmstudio\""), "{own}"); + } } diff --git a/codex-rs/local/cli/src/local_cfg.rs b/codex-rs/local/cli/src/local_cfg.rs new file mode 100644 index 000000000000..a2a0ca69abd0 --- /dev/null +++ b/codex-rs/local/cli/src/local_cfg.rs @@ -0,0 +1,55 @@ +//! codex-local's own settings file, `codex-local.toml` in the local `CODEX_HOME`. +//! +//! Upstream warns on (and `--strict-config` rejects) unknown `config.toml` tables, so +//! `[local] engine = ...` lives here, where only codex-local reads it. + +use std::fs; + +use anyhow::Context; +use anyhow::Result; +use toml_edit::DocumentMut; + +use crate::Homes; + +pub(crate) const FILE: &str = "codex-local.toml"; + +const TEMPLATE: &str = "# codex-local settings (read only by codex-local).\n\ +# Engine: ollama | lmstudio | llamacpp. Unset means ollama, with a hint if the model is\n\ +# found on another engine's default port.\n\ +# [local]\n\ +# engine = \"ollama\"\n"; + +/// Creates `codex-local.toml` if absent, folds a legacy `[local]` table out of +/// `config.toml` (once, with a warning), and returns the configured engine. +pub(crate) fn engine(h: &Homes) -> Result> { + let path = h.local.join(FILE); + if !path.exists() { + fs::write(&path, TEMPLATE).with_context(|| format!("write {}", path.display()))?; + } + let mut doc: DocumentMut = fs::read_to_string(&path)? + .parse() + .with_context(|| format!("parse {}", path.display()))?; + let read = |d: &DocumentMut| { + d.get("local") + .and_then(|l| l.get("engine")) + .and_then(|e| e.as_str()) + .map(str::to_string) + }; + let cfg_path = h.local.join("config.toml"); + if let Ok(text) = fs::read_to_string(&cfg_path) + && let Ok(mut cfg) = text.parse::() + && let Some(legacy) = cfg.remove("local") + { + if read(&doc).is_none() + && let Some(e) = legacy.get("engine").and_then(|e| e.as_str()) + { + doc["local"]["engine"] = toml_edit::value(e); + fs::write(&path, doc.to_string())?; + } + fs::write(&cfg_path, cfg.to_string())?; + eprintln!( + "codex-local: moved `[local]` from config.toml to {FILE} (upstream rejects unknown tables)" + ); + } + Ok(read(&doc)) +} diff --git a/codex-rs/local/cli/src/main.rs b/codex-rs/local/cli/src/main.rs index b3598d874b76..5a85bbf9972b 100644 --- a/codex-rs/local/cli/src/main.rs +++ b/codex-rs/local/cli/src/main.rs @@ -64,16 +64,18 @@ fn main() -> anyhow::Result<()> { probe_model, } => (argv, codex_home, preflight_root, probe_model), }; - if let Some(root) = &preflight_root - && let Err(e) = codex_local_cli::preflight_check(root) - { - eprintln!("{}", codex_local_cli::preflight_failure_message(root, &e)); - std::process::exit(1); - } - if let (Some(root), Some(model)) = (&preflight_root, &probe_model) - && let Some(hint) = codex_local_cli::engine_hint(root, model) - { - eprintln!("{hint}"); + if let Some(root) = &preflight_root { + let down = codex_local_cli::preflight_check(root); + // Probe the other engines' ports even when the default is unreachable. + if let Some(model) = &probe_model + && let Some(hint) = codex_local_cli::engine_hint(root, model) + { + eprintln!("{hint}"); + } + if let Err(e) = down { + eprintln!("{}", codex_local_cli::preflight_failure_message(root, &e)); + std::process::exit(1); + } } // SAFETY: still single-threaded; no runtime or worker threads exist yet. unsafe { std::env::set_var("CODEX_HOME", &codex_home) }; diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index 3d6e7df4353f..fd73f06d2983 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -21,11 +21,13 @@ pub struct Applied { pub engine: Engine, /// Flag, env or config set the engine (else it is the `ollama` default). pub engine_explicit: bool, - /// A URL flag or env var chose the root. + /// A URL flag, env var or non-default config provider URL chose the root. pub url_explicit: bool, /// The model we injected (`None` when `-m`/`-c model=` was given, or llama.cpp /// still needs its first `/v1/models` id). pub model: Option, + /// The model the run will use: the `-m`/`-c model=` value, else the injected one. + pub effective_model: Option, } /// Strip `--engine`, `--base-url`/`--ollama-url` (ours, unknown to upstream) and inject `-c` overrides right @@ -45,11 +47,13 @@ pub fn apply_overrides( env: EnvFn, config_model: Option, config_engine: Option, + config_root: Option, ) -> Result { let mut rest = Vec::with_capacity(args.len()); let mut flag_url: Option = None; let mut flag_engine: Option = None; let mut has_model_flag = false; + let mut flag_model: Option = None; let mut it = args.into_iter(); let argv0 = it.next(); let mut after_dd = false; @@ -82,10 +86,22 @@ pub fn apply_overrides( } else if let Some(v) = s.strip_prefix("--engine=") { flag_engine = Some(v.to_string()); continue; - } else if s == "-m" || s == "--model" || s.starts_with("--model=") || s.starts_with("-m=") { + } else if s == "-m" || s == "--model" { has_model_flag = true; - } else if s.starts_with("model=") && rest.last().is_some_and(|p| p == "-c") { + rest.push(a); + if let Some(v) = it.next() { + flag_model = Some(v.to_string_lossy().into_owned()); + rest.push(v); + } + continue; + } else if let Some(v) = s.strip_prefix("--model=").or_else(|| s.strip_prefix("-m=")) { has_model_flag = true; + flag_model = Some(v.to_string()); + } else if let Some(v) = s.strip_prefix("model=") + && rest.last().is_some_and(|p| p == "-c") + { + has_model_flag = true; + flag_model = Some(v.trim_matches('"').to_string()); } rest.push(a); } @@ -96,13 +112,15 @@ pub fn apply_overrides( config_engine.as_deref(), ) .map_err(anyhow::Error::msg)?; - let root = match flag_url { + let explicit_root = match flag_url { Some(u) => Some(resolve_ollama_root(&u)?), None => env_ollama_root(env)?, }; - let url_explicit = root.is_some(); - // Seeded config points at Ollama; another engine needs its own default root. - let root = root.or_else(|| (engine != Engine::Ollama).then(|| engine.default_root())); + // Explicit URLs (flag, env, non-default config provider URL) beat engine default ports. + let url_explicit = explicit_root.is_some() || config_root.is_some(); + let root = explicit_root.or_else(|| { + (engine != Engine::Ollama && config_root.is_none()).then(|| engine.default_root()) + }); // The seeded Ollama default model is wrong for another engine. let config_model = config_model.filter(|m| engine == Engine::Ollama || m != DEFAULT_MODEL); let model = if has_model_flag { @@ -112,6 +130,7 @@ pub fn apply_overrides( .or(config_model) .or_else(|| engine.default_model().map(str::to_string)) }; + let effective_model = flag_model.or_else(|| model.clone()); let mut out: Vec = argv0.into_iter().collect(); let mut push_c = |kv: String| { out.push("-c".into()); @@ -151,6 +170,7 @@ pub fn apply_overrides( engine_explicit, url_explicit, model, + effective_model, }) } @@ -165,7 +185,7 @@ mod tests { .map(|(_, v)| v.to_string()) }; let a = args.iter().map(OsString::from).collect(); - apply_overrides(a, &f, Some("cfgm".to_string()), None) + apply_overrides(a, &f, Some("cfgm".to_string()), None, None) .unwrap() .argv .iter() @@ -193,6 +213,7 @@ mod tests { &f, Some(DEFAULT_MODEL.to_string()), cfg_engine.map(str::to_string), + None, ) .unwrap() } @@ -230,6 +251,7 @@ mod tests { vec!["x".into(), "--engine=bogus".into()], &|_| None, None, + None, None ) .is_err() @@ -265,6 +287,23 @@ mod tests { "model_providers.codex-local.base_url=\"http://h:8/v1\"" )); assert_eq!(a.model, None); + // A custom config provider URL beats the engine's default port. + let f = |_: &str| None; + let a = apply_overrides( + vec!["x".into(), "--engine=lmstudio".into()], + &f, + None, + None, + Some("http://remote:9".into()), + ) + .unwrap(); + assert!( + a.url_explicit + && !a + .argv + .iter() + .any(|s| s.to_string_lossy().contains("base_url")) + ); } #[test] diff --git a/codex-rs/local/cli/src/preflight.rs b/codex-rs/local/cli/src/preflight.rs index 813ab4b66fff..69e2c12b47a1 100644 --- a/codex-rs/local/cli/src/preflight.rs +++ b/codex-rs/local/cli/src/preflight.rs @@ -75,6 +75,11 @@ pub(crate) fn needs_preflight(args: &[OsString], env: EnvFn) -> bool { if env("CODEX_LOCAL_SKIP_PREFLIGHT").is_some_and(|v| v != "0") { return false; } + uses_model(args) +} + +/// Whether the subcommand talks to the model at all (independent of the skip switch). +pub(crate) fn uses_model(args: &[OsString]) -> bool { let mut it = args.iter().skip(1).map(|a| a.to_string_lossy()); while let Some(a) = it.next() { match a.as_ref() { @@ -169,14 +174,15 @@ pub fn engine_hint(root: &str, model: &str) -> Option { } fn hint_with(root: &str, model: &str, others: &[(Engine, String)]) -> Option { - let here = list(root, TIMEOUT).ok()?; + // An unreachable root is the case the hint most helps: treat it as "model absent". + let here = list(root, TIMEOUT).unwrap_or_default(); if here.iter().any(|m| m.id == model) { return None; } let e = find_model_elsewhere(model, others, Duration::from_secs(1))?; Some(format!( "codex-local: model `{model}` not found at {root}, but {e} serves it; \ - use --engine {e} or set `[local] engine = \"{e}\"` in config.toml." + use --engine {e} or set `[local] engine = \"{e}\"` in codex-local.toml." )) } diff --git a/codex-rs/local/models/src/lib.rs b/codex-rs/local/models/src/lib.rs index b97b07b8b4d8..0b46241a9881 100644 --- a/codex-rs/local/models/src/lib.rs +++ b/codex-rs/local/models/src/lib.rs @@ -99,7 +99,7 @@ impl OllamaModelsEndpoint { let mut set = tokio::task::JoinSet::new(); for (i, m) in listed.iter().enumerate() { // Only Ollama-shaped answers need `/api/show`; others report the length inline. - if m.context_length.is_none() { + if m.context_length.is_none() && m.ollama_shaped { let (this, n) = (self.clone_light(), m.id.clone()); set.spawn(async move { (i, this.context_window(&n).await) }); } @@ -131,7 +131,8 @@ impl OllamaModelsEndpoint { info.visibility = ModelVisibility::List; info.priority = i32::try_from(index).unwrap_or(i32::MAX); info.context_window = Some(window); - info.max_context_window = Some(window); + // A default, not a cap: the config's `model_context_window` must be able to win. + info.max_context_window = None; info.used_fallback_model_metadata = false; if self.freeform_apply_patch { info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); @@ -321,6 +322,7 @@ mod tests { .collect(); assert_eq!(got, vec![("a:1", 1000), ("b:2", 2000), ("c:3", 3000)]); assert!(models.iter().all(|m| m.apply_patch_tool_type.is_some())); + assert!(models.iter().all(|m| m.max_context_window.is_none())); } #[tokio::test] diff --git a/codex-rs/local/models/src/v1.rs b/codex-rs/local/models/src/v1.rs index dff0b7d3e31d..de604d251b11 100644 --- a/codex-rs/local/models/src/v1.rs +++ b/codex-rs/local/models/src/v1.rs @@ -9,6 +9,9 @@ pub struct V1Model { pub id: String, /// Per-slot context window when the engine reports it (PAIR/LM Studio facade). pub context_length: Option, + /// Not recognisably another engine's body (llama.cpp `meta`/`owned_by`, LM Studio + /// `publisher`), so Ollama's `/api/show` may answer. + pub ollama_shaped: bool, } /// `root` has no `/v1`. Errors are short human strings. @@ -35,6 +38,9 @@ pub async fn list_v1_models( out.push(V1Model { id: id.to_string(), context_length: m["context_length"].as_i64().filter(|n| *n > 0), + ollama_shaped: m.get("meta").is_none() + && m.get("publisher").is_none() + && m["owned_by"].as_str() != Some("llamacpp"), }); } } From eedffae51176bed500e6cb890c0893d0a21f5eb4 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Fri, 16 Oct 2026 12:53:55 -0700 Subject: [PATCH 3/7] codex-local: make LM Studio the default engine (replaces Ollama) Default engine is LmStudio (http://127.0.0.1:1234, placeholder model LMSTUDIO_DEFAULT_MODEL, max_threads 7); precedence unchanged. Seeded root and model are the LM Studio defaults and read as "unset"; any engine-default root or model in config.toml (incl. old Ollama seeds) is ignored in favour of the winning engine's own, so explicit ollama (flag/env/toml) still works. Probe hint now suggests Ollama/llama.cpp. Docs and skill text updated. Claude-Session: https://claude.ai/code/session_01RPCzgwU247gMtjybR5v4iR Co-Authored-By: Claude Opus 5.5 --- codex-rs/local/cli/build.rs | 6 ++- codex-rs/local/cli/src/lib.rs | 30 ++++++------ codex-rs/local/cli/src/local_cfg.rs | 2 +- codex-rs/local/cli/src/overrides.rs | 68 ++++++++++++++++++++-------- codex-rs/local/cli/src/preflight.rs | 4 +- codex-rs/local/cli/src/seed.rs | 15 +++--- codex-rs/local/models/src/engine.rs | 29 ++++++++++-- codex-rs/local/models/src/lib.rs | 6 ++- local/docs/plan-deltas.md | 3 +- local/scripts/verify_install.py | 2 +- local/skills/delegate-local/SKILL.md | 8 ++-- 11 files changed, 116 insertions(+), 57 deletions(-) diff --git a/codex-rs/local/cli/build.rs b/codex-rs/local/cli/build.rs index 2989e05ee261..cf3085f27867 100644 --- a/codex-rs/local/cli/build.rs +++ b/codex-rs/local/cli/build.rs @@ -24,7 +24,11 @@ fn main() { } fn git(dir: &str, args: &[&str]) -> Option { - let out = Command::new("git").current_dir(dir).args(args).output().ok()?; + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .ok()?; if !out.status.success() { return None; } diff --git a/codex-rs/local/cli/src/lib.rs b/codex-rs/local/cli/src/lib.rs index accefb976f4b..a4d131bed4ee 100644 --- a/codex-rs/local/cli/src/lib.rs +++ b/codex-rs/local/cli/src/lib.rs @@ -29,7 +29,8 @@ pub use seed::seed_config; pub use sync::sync_config; pub use url::resolve_ollama_root; -pub const DEFAULT_MODEL: &str = "qwen3.8:27b"; +/// Seeded model: the default engine's (LM Studio) placeholder model. +pub const DEFAULT_MODEL: &str = codex_local_models::LMSTUDIO_DEFAULT_MODEL; pub const PROVIDER_ID: &str = "codex-local"; /// `+local.`; the sha comes from `build.rs`. @@ -62,7 +63,7 @@ pub enum Action { Run { argv: Vec, codex_home: PathBuf, - /// Ollama root to probe before delegating; `None` skips the preflight. + /// Engine root to probe before delegating; `None` skips the preflight. preflight_root: Option, /// Model to look for on the other engines' default ports when the engine was /// never chosen explicitly; `None` skips the probe. @@ -79,12 +80,13 @@ pub fn prepare(args: Vec, env: EnvFn, user_home: Option) -> R let homes = resolve_homes(env, user_home)?; ensure_home(&homes)?; // Seed from env/built-ins only; a one-off --ollama-url/--engine flag must not persist. - // The seeded URL is always Ollama's (an engine's default port is injected at run time, - // after the winning engine is known), and a bad env engine must not abort here. + // The seeded URL is always the default engine's (LM Studio; any engine's default port is + // injected at run time, after the winning engine is known, so a seeded default root reads + // as "unset"), and a bad env engine must not abort here. let seed_engine = env("CODEX_LOCAL_ENGINE") .and_then(|v| v.parse::().ok()) .unwrap_or_default(); - let root = env_ollama_root(env)?.unwrap_or_else(|| Engine::Ollama.default_root()); + let root = env_ollama_root(env)?.unwrap_or_else(|| Engine::default().default_root()); seed_config(&homes, &root, seed_engine)?; let config_engine = local_cfg::engine(&homes)?; if first == Some("sync-config") { @@ -152,8 +154,9 @@ fn effective_root(argv: &[OsString], homes: &Homes, fallback: &str) -> String { .unwrap_or_else(|| fallback.to_string()) } -/// Provider root from config.toml, unless it is just the seeded Ollama default (which -/// must not beat another engine's default port). +/// Provider root from config.toml, unless it is just a seeded engine-default root (which +/// must not beat the winning engine's default port). This also covers homes seeded with +/// Ollama's root before LM Studio became the default. fn config_root(cfg: Option<&toml::Table>) -> Option { let url = cfg? .get("model_providers")? @@ -164,7 +167,7 @@ fn config_root(cfg: Option<&toml::Table>) -> Option { .trim_end_matches('/') .trim_end_matches("/v1") .to_string(); - (root != Engine::Ollama.default_root()).then_some(root) + (!Engine::is_default_root(&root)).then_some(root) } pub(crate) fn env_ollama_root(env: EnvFn) -> Result> { @@ -235,7 +238,7 @@ mod tests { assert_eq!(cfg["model"].as_str(), Some(DEFAULT_MODEL)); assert_eq!( cfg["model_providers"][PROVIDER_ID]["base_url"].as_str(), - Some("http://localhost:11434/v1") + Some("http://127.0.0.1:1234/v1") ); assert_eq!( cfg["shell_environment_policy"]["set"]["CODEX_HOME"].as_str(), @@ -304,18 +307,19 @@ mod tests { ); let a = |v: &[&str]| v.iter().map(OsString::from).collect::>(); let act = prepare(a(&["x", "--engine", "ollama", "exec"]), &env, None).unwrap(); - assert_eq!(base_url_arg(&act), None); + // The seeded LM Studio root reads as unset, so Ollama's own port is injected. + assert!(base_url_arg(&act).unwrap().contains(":11434/v1")); let env = run_env( tmp.path(), &[ - ("CODEX_LOCAL_ENGINE", "lmstudio"), + ("CODEX_LOCAL_ENGINE", "llamacpp"), ("CODEX_LOCAL_SKIP_PREFLIGHT", "1"), ], ); let cfg = fs::read_to_string(tmp.path().join("local/config.toml")).unwrap(); - assert!(cfg.contains("localhost:11434/v1"), "{cfg}"); + assert!(cfg.contains("127.0.0.1:1234/v1"), "{cfg}"); let act = prepare(a(&["x", "exec"]), &env, None).unwrap(); - assert!(base_url_arg(&act).unwrap().contains(":1234/v1")); + assert!(base_url_arg(&act).unwrap().contains(":8080/v1")); } #[test] diff --git a/codex-rs/local/cli/src/local_cfg.rs b/codex-rs/local/cli/src/local_cfg.rs index a2a0ca69abd0..37e5211e3f38 100644 --- a/codex-rs/local/cli/src/local_cfg.rs +++ b/codex-rs/local/cli/src/local_cfg.rs @@ -14,7 +14,7 @@ use crate::Homes; pub(crate) const FILE: &str = "codex-local.toml"; const TEMPLATE: &str = "# codex-local settings (read only by codex-local).\n\ -# Engine: ollama | lmstudio | llamacpp. Unset means ollama, with a hint if the model is\n\ +# Engine: ollama | lmstudio | llamacpp. Unset means lmstudio, with a hint if the model is\n\ # found on another engine's default port.\n\ # [local]\n\ # engine = \"ollama\"\n"; diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index fd73f06d2983..c10e46bd4145 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -6,7 +6,6 @@ use anyhow::bail; use codex_local_models::Engine; use codex_local_models::resolve_engine; -use crate::DEFAULT_MODEL; use crate::EnvFn; use crate::PROVIDER_ID; use crate::env_ollama_root; @@ -19,7 +18,7 @@ pub(crate) const MAX_RETRIES: u64 = 2; pub struct Applied { pub argv: Vec, pub engine: Engine, - /// Flag, env or config set the engine (else it is the `ollama` default). + /// Flag, env or config set the engine (else it is the `lmstudio` default). pub engine_explicit: bool, /// A URL flag, env var or non-default config provider URL chose the root. pub url_explicit: bool, @@ -35,8 +34,8 @@ pub struct Applied { /// /// Precedence: flags (`-m`, `--base-url`/`--ollama-url`) > `CODEX_LOCAL_MODEL` > /// `CODEX_LOCAL_URL` > `CODEX_LOCAL_OLLAMA_URL` > `OLLAMA_HOST` > config.toml > built-ins. -/// The engine is `--engine` > `CODEX_LOCAL_ENGINE` > config `[local] engine` > `ollama`; -/// a non-Ollama engine with no URL override gets its default port and default model. `-m` is left to +/// The engine is `--engine` > `CODEX_LOCAL_ENGINE` > config `[local] engine` > `lmstudio`; +/// the winning engine with no URL override gets its default port and default model. `-m` is left to /// upstream (it already wins), so the env model is injected only when no model flag is /// present, else the configured model. Both the provider and the model are always /// injected: upstream's TUI only overrides a resumed session's rollout provider/model @@ -118,11 +117,11 @@ pub fn apply_overrides( }; // Explicit URLs (flag, env, non-default config provider URL) beat engine default ports. let url_explicit = explicit_root.is_some() || config_root.is_some(); - let root = explicit_root.or_else(|| { - (engine != Engine::Ollama && config_root.is_none()).then(|| engine.default_root()) - }); - // The seeded Ollama default model is wrong for another engine. - let config_model = config_model.filter(|m| engine == Engine::Ollama || m != DEFAULT_MODEL); + let root = explicit_root.or_else(|| config_root.is_none().then(|| engine.default_root())); + // A seeded default model of another engine (LM Studio's, or Ollama's in homes seeded + // before LM Studio became the default) is wrong for the winning engine. + let config_model = config_model + .filter(|m| engine.default_model() == Some(m.as_str()) || !Engine::is_default_model(m)); let model = if has_model_flag { None } else { @@ -152,7 +151,7 @@ pub fn apply_overrides( push_c(format!( "model_providers.{PROVIDER_ID}.stream_max_retries={MAX_RETRIES}" )); - // Ollama fails the built-in server-side web_search tool; also covers old installs. + // Local engines fail the built-in server-side web_search tool; also covers old installs. push_c("web_search=\"disabled\"".to_string()); // No app-server daemon (needs a package we don't ship; also skips our in-process hooks). push_c("features.daemon_auto_start=false".to_string()); @@ -177,6 +176,7 @@ pub fn apply_overrides( #[cfg(test)] mod tests { use super::*; + use crate::DEFAULT_MODEL; fn run(args: &[&str], env: &[(&str, &str)]) -> Vec { let f = |k: &str| { @@ -185,12 +185,19 @@ mod tests { .map(|(_, v)| v.to_string()) }; let a = args.iter().map(OsString::from).collect(); - apply_overrides(a, &f, Some("cfgm".to_string()), None, None) - .unwrap() - .argv - .iter() - .map(|s| s.to_string_lossy().into_owned()) - .collect() + // A custom config URL keeps the default engine's own root out of these argv shapes. + apply_overrides( + a, + &f, + Some("cfgm".to_string()), + None, + Some("http://cfg:1".into()), + ) + .unwrap() + .argv + .iter() + .map(|s| s.to_string_lossy().into_owned()) + .collect() } const RQ: &str = "model_providers.codex-local.request_max_retries=2"; @@ -225,12 +232,33 @@ mod tests { #[test] fn engine_precedence_flag_env_config_default() { let a = applied(&["x", "exec"], &[], None); - assert_eq!((a.engine, a.engine_explicit), (Engine::Ollama, false)); + assert_eq!((a.engine, a.engine_explicit), (Engine::LmStudio, false)); + assert!(has( + &a, + "model_providers.codex-local.base_url=\"http://127.0.0.1:1234/v1\"" + )); + assert!(has(&a, &format!("model=\"{DEFAULT_MODEL}\""))); + // Explicit ollama still resolves (here the config carries Ollama's old seed). + let f = |_: &str| None; + let o = apply_overrides( + vec!["x".into(), "--engine=ollama".into()], + &f, + Some("qwen3.8:27b".into()), + None, + None, + ) + .unwrap(); + assert_eq!((o.engine, o.engine_explicit), (Engine::Ollama, true)); assert!( - !a.argv + o.argv .iter() - .any(|s| s.to_string_lossy().contains("base_url")) + .any(|s| s == "model_providers.codex-local.base_url=\"http://127.0.0.1:11434/v1\"") ); + // Old Ollama-seeded config, no explicit engine: now LM Studio. + let m = + apply_overrides(vec!["x".into()], &f, Some("qwen3.8:27b".into()), None, None).unwrap(); + assert_eq!(m.engine, Engine::LmStudio); + assert_eq!(m.model.as_deref(), Some(DEFAULT_MODEL)); let a = applied(&["x", "exec"], &[], Some("lmstudio")); assert_eq!((a.engine, a.engine_explicit), (Engine::LmStudio, true)); let a = applied( @@ -263,7 +291,7 @@ mod tests { let a = applied(&["x", "--engine=lmstudio", "exec"], &[], None); assert!(has( &a, - "model_providers.codex-local.base_url=\"http://localhost:1234/v1\"" + "model_providers.codex-local.base_url=\"http://127.0.0.1:1234/v1\"" )); assert!(has(&a, "model=\"qwen3.8-27b-mlx\"")); assert!(!a.url_explicit); diff --git a/codex-rs/local/cli/src/preflight.rs b/codex-rs/local/cli/src/preflight.rs index 69e2c12b47a1..9cd7b528b475 100644 --- a/codex-rs/local/cli/src/preflight.rs +++ b/codex-rs/local/cli/src/preflight.rs @@ -70,7 +70,7 @@ const VALUE_FLAGS: &[&str] = &[ "--remote-auth-token-env", ]; -/// Whether this invocation needs a reachable Ollama (exec, review, resume, the TUI, ...). +/// Whether this invocation needs a reachable engine (exec, review, resume, the TUI, ...). pub(crate) fn needs_preflight(args: &[OsString], env: EnvFn) -> bool { if env("CODEX_LOCAL_SKIP_PREFLIGHT").is_some_and(|v| v != "0") { return false; @@ -167,7 +167,7 @@ pub(crate) fn find_model_elsewhere( pub fn engine_hint(root: &str, model: &str) -> Option { let others: Vec<_> = Engine::ALL .into_iter() - .filter(|e| *e != Engine::Ollama) + .filter(|e| *e != Engine::default()) .map(|e| (e, format!("http://127.0.0.1:{}", e.default_port()))) .collect(); hint_with(root, model, &others) diff --git a/codex-rs/local/cli/src/seed.rs b/codex-rs/local/cli/src/seed.rs index ddb2594ac9c6..30cc9ea6a7f8 100644 --- a/codex-rs/local/cli/src/seed.rs +++ b/codex-rs/local/cli/src/seed.rs @@ -64,7 +64,7 @@ pub(crate) fn seed_toml(root: &str, stock_home: &str, engine: Engine) -> Result< ("daemon_auto_start", Value::Boolean(false)), ]), ); - // Ollama can't run the server-side `web_search` tool; use the local MCP server. + // Local engines can't run the server-side `web_search` tool; use the local MCP server. t.insert("web_search".into(), s("disabled")); t.insert( "mcp_servers".into(), @@ -97,18 +97,19 @@ mod tests { use codex_local_models::Engine; #[test] - fn seeds_six_agent_threads() { - let body = super::seed_toml("http://127.0.0.1:11434", "/h", Engine::Ollama).unwrap(); + fn seeds_lmstudio_defaults() { + let body = super::seed_toml("http://127.0.0.1:1234", "/h", Engine::default()).unwrap(); let v: toml::Value = toml::from_str(&body).unwrap(); - assert_eq!(v["agents"]["max_threads"].as_integer(), Some(6)); + assert_eq!(v["model"].as_str(), Some(crate::DEFAULT_MODEL)); + assert_eq!(v["agents"]["max_threads"].as_integer(), Some(7)); assert_eq!(v["web_search"].as_str(), Some("disabled")); assert_eq!(v["features"]["daemon_auto_start"].as_bool(), Some(false)); let ws = &v["mcp_servers"]["web-search"]; assert_eq!(ws["command"].as_str(), Some("uvx")); assert_eq!(ws["default_tools_approval_mode"].as_str(), Some("approve")); assert_eq!(ws["args"][0].as_str(), Some(super::WEB_SEARCH_PKG)); - let lm = super::seed_toml("http://localhost:1234", "/h", Engine::LmStudio).unwrap(); - let lm: toml::Value = toml::from_str(&lm).unwrap(); - assert_eq!(lm["agents"]["max_threads"].as_integer(), Some(7)); + let ol = super::seed_toml("http://127.0.0.1:11434", "/h", Engine::Ollama).unwrap(); + let ol: toml::Value = toml::from_str(&ol).unwrap(); + assert_eq!(ol["agents"]["max_threads"].as_integer(), Some(6)); } } diff --git a/codex-rs/local/models/src/engine.rs b/codex-rs/local/models/src/engine.rs index 7689612e1a97..dd41dc527238 100644 --- a/codex-rs/local/models/src/engine.rs +++ b/codex-rs/local/models/src/engine.rs @@ -3,10 +3,13 @@ use std::fmt; use std::str::FromStr; +/// Placeholder default model for LM Studio (the default engine); change it here only. +pub const LMSTUDIO_DEFAULT_MODEL: &str = "qwen3.8-27b-mlx"; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Engine { - #[default] Ollama, + #[default] LmStudio, LlamaCpp, } @@ -31,14 +34,29 @@ impl Engine { } pub fn default_root(self) -> String { - format!("http://localhost:{}", self.default_port()) + format!("http://127.0.0.1:{}", self.default_port()) + } + + /// Whether `root` is some engine's built-in default root (either loopback spelling; + /// older homes were seeded with `localhost`). Such a root in `config.toml` is a seed, + /// not a user choice, so it must not beat the winning engine's own port. + pub fn is_default_root(root: &str) -> bool { + Engine::ALL.iter().any(|e| { + let p = e.default_port(); + root == format!("http://127.0.0.1:{p}") || root == format!("http://localhost:{p}") + }) + } + + /// Whether `model` is the built-in default model of some engine (a seed, not a choice). + pub fn is_default_model(model: &str) -> bool { + Engine::ALL.iter().any(|e| e.default_model() == Some(model)) } /// `None` for llama.cpp: it serves whatever it loaded (first `/v1/models` id). pub fn default_model(self) -> Option<&'static str> { match self { Engine::Ollama => Some("qwen3.8:27b"), - Engine::LmStudio => Some("qwen3.8-27b-mlx"), + Engine::LmStudio => Some(LMSTUDIO_DEFAULT_MODEL), Engine::LlamaCpp => None, } } @@ -69,7 +87,7 @@ impl FromStr for Engine { } } -/// Precedence: flag > env > config > default (`Ollama`). The bool is whether any +/// Precedence: flag > env > config > default (`LmStudio`). The bool is whether any /// source set it explicitly. pub fn resolve_engine( flag: Option<&str>, @@ -91,7 +109,8 @@ mod tests { assert_eq!("LMStudio".parse(), Ok(Engine::LmStudio)); assert!("vllm".parse::().is_err()); let r = resolve_engine; - assert_eq!(r(None, None, None), Ok((Engine::Ollama, false))); + assert_eq!(r(None, None, None), Ok((Engine::LmStudio, false))); + assert_eq!(r(None, None, Some("ollama")), Ok((Engine::Ollama, true))); assert_eq!( r(None, None, Some("lmstudio")), Ok((Engine::LmStudio, true)) diff --git a/codex-rs/local/models/src/lib.rs b/codex-rs/local/models/src/lib.rs index 0b46241a9881..a06148f827d8 100644 --- a/codex-rs/local/models/src/lib.rs +++ b/codex-rs/local/models/src/lib.rs @@ -1,4 +1,4 @@ -//! Ollama-backed model catalog for `codex-local` (`/model` picker, req 8). +//! Local-engine model catalog for `codex-local` (`/model` picker, req 8). //! //! Lists models via `GET /v1/models` (every engine serves it). The context window comes //! from the entry's `context_length`, else (Ollama-shaped bodies only) `POST /api/show`. @@ -7,6 +7,7 @@ mod engine; mod v1; pub use engine::Engine; +pub use engine::LMSTUDIO_DEFAULT_MODEL; pub use engine::resolve_engine; pub use v1::V1Model; pub use v1::list_v1_models; @@ -172,7 +173,8 @@ impl ModelsEndpointClient for OllamaModelsEndpoint { } fn factory(info: &ModelProviderInfo) -> SharedModelsManager { - let base = info.base_url.as_deref().unwrap_or("http://127.0.0.1:11434"); + let default_root = Engine::default().default_root(); + let base = info.base_url.as_deref().unwrap_or(&default_root); let root = base.trim_end_matches('/').trim_end_matches("/v1"); Arc::new(LoadsOnDemand(Arc::new( OpenAiModelsManager::new_without_cache( diff --git a/local/docs/plan-deltas.md b/local/docs/plan-deltas.md index a6430c7a78de..fe12cf392295 100644 --- a/local/docs/plan-deltas.md +++ b/local/docs/plan-deltas.md @@ -10,9 +10,10 @@ Where the build differs from the approved plan. - **Hook budget:** 135 lines / 12 files (actual 122/11), above the plan's estimate of about 85/7. - **Plugins:** `features.plugins=false` is set to stop the chatgpt.com plugin warm-up. This also disables plugin-bundled skills. - **Session affinity and the mid-history fix:** live in PAIR (Phase 9), not in codex-local. -- **Models catalog:** `codex-local-models` builds its manager with `.with_provider_catalog()`, so Ollama's catalog is authoritative and no bundled gpt-* models are merged in; `ensure_loaded` lazily fetches because the initial list is empty. +- **Models catalog:** `codex-local-models` builds its manager with `.with_provider_catalog()`, so the local engine's catalog is authoritative and no bundled gpt-* models are merged in; `ensure_loaded` lazily fetches because the initial list is empty. - **Agent threads:** seeded `[agents] max_threads` is now 6 (existing configs keep 4, never rewritten); override with `CODEX_LOCAL_MAX_AGENTS=` (positive int, beats config.toml, invalid warns and is ignored) or the `agents.max_threads` config key. - **UPSTREAM_SYNC_TOKEN:** the upstream-sync workflow needs this secret (PAT or App token with workflows scope) to push and open the PR whenever the merge touches `.github/workflows/**`; without it that run fails early, otherwise it falls back to GITHUB_TOKEN. +- **Default engine:** LM Studio (`http://127.0.0.1:1234`, placeholder model `LMSTUDIO_DEFAULT_MODEL` in `codex-local-models`, `max_threads` 7), replacing Ollama; precedence flag > env > `codex-local.toml` > default. Seeded root/model are the LM Studio defaults and read as "unset"; old Ollama-seeded homes with no explicit engine now resolve to LM Studio (use `engine = "ollama"`, `--engine ollama` or `CODEX_LOCAL_ENGINE=ollama` to keep Ollama). - **Local web search:** Ollama fails the built-in server-side `web_search` tool, so config seeds `web_search = "disabled"` (also forced via `-c` on every run) and `[mcp_servers.web-search]` runs `uvx duckduckgo-mcp-server==0.7.0` (search + fetch_content, no key); `sync-config` keeps that entry; install/verify warn if `uvx` is missing. - **No app-server daemon:** upstream auto-starts/discovers a daemon for the TUI that needs a full `codex-package.json` package (a bare binary fails with "no complete local package") and would bypass in-process registrations; codex-local injects `--no-daemon` (plain TUI/resume/fork, unless `--remote`) plus `-c features.daemon_auto_start=false`, and seeds that feature off; stock is untouched. diff --git a/local/scripts/verify_install.py b/local/scripts/verify_install.py index c6072d95b91c..bcd7c6de2c2a 100644 --- a/local/scripts/verify_install.py +++ b/local/scripts/verify_install.py @@ -146,7 +146,7 @@ def quick(rep, exe, sums) -> None: srv, url = mock_ollama.start() try: env = {**os.environ, "CODEX_HOME": str(stock), "CODEX_LOCAL_HOME": str(local), - "CODEX_LOCAL_OLLAMA_URL": url} + "CODEX_LOCAL_OLLAMA_URL": url, "CODEX_LOCAL_ENGINE": "ollama"} env.pop("OLLAMA_HOST", None) rc, out, err = _lib.run([exe, "sync-config"], env=env) if rc != 0: diff --git a/local/skills/delegate-local/SKILL.md b/local/skills/delegate-local/SKILL.md index 403309e857d5..965e794f143e 100644 --- a/local/skills/delegate-local/SKILL.md +++ b/local/skills/delegate-local/SKILL.md @@ -1,7 +1,7 @@ --- name: delegate-local description: > - Delegate bounded, token-heavy, judgment-light sub-tasks to the local Ollama model + Delegate bounded, token-heavy, judgment-light sub-tasks to the local model (LM Studio by default) (qwen3.8:27b) via `codex-local exec` to cut cloud-token usage. Use when the user asks to "delegate locally", "offload this", "run it on the local model", runs /delegate-local, or when a task has large mechanical read / edit / summarize / search / verify work that the @@ -20,11 +20,11 @@ reliable ONLY on narrow, fully-specified, single-shot units paired with a cheap ## Setup (zero-config) `codex-local` is installed beside stock `codex` (`local/scripts/install.py`). It defaults to -model `qwen3.8:27b` on `http://localhost:11434` (plain Ollama or a PAIR proxy). Overrides: +engine LM Studio, model `qwen3.8-27b-mlx` (placeholder) on `http://127.0.0.1:1234` (PAIR's LM Studio facade). Ollama (`qwen3.8:27b`, port 11434) and llama.cpp are one flag away. Overrides: `-m MODEL`, `--base-url URL` (alias `--ollama-url`), `--engine ollama|lmstudio|llamacpp`, env `CODEX_LOCAL_MODEL` / `CODEX_LOCAL_URL` / `CODEX_LOCAL_OLLAMA_URL` / `OLLAMA_HOST` / `CODEX_LOCAL_ENGINE`. The engine can also be set in codex-local's config.toml -(`[local]` table, `engine = "lmstudio"`); precedence is flag > env > config > `ollama`. No profiles, tunnels, fleet files or node locks. Check with +(`[local]` table, `engine = "lmstudio"`); precedence is flag > env > config > `lmstudio`. Homes seeded with Ollama's URL/model and no explicit engine now resolve to LM Studio; keep Ollama with `engine = "ollama"`, `--engine ollama` or `CODEX_LOCAL_ENGINE=ollama`. No profiles, tunnels, fleet files or node locks. Check with `verify_install.py` (full) if a unit fails oddly. ## Green / Yellow / Red decisioning @@ -89,7 +89,7 @@ Scope verifiers tightly (explicit paths or `--include`); stale artifacts such as shell-tool rule. - Files changed but the verifier fails: it acted wrongly, or the verifier is mis-scoped. Check the verifier first, then the diff. -- `codex-local exec` exits non-zero or hangs: check Ollama is up (`curl $URL/api/tags`), +- `codex-local exec` exits non-zero or hangs: check the engine is up (`curl $URL/v1/models`), then escalate or split the unit. ## Honesty about savings From cf295415e127c56d690379664eb8fc18b08aa82e Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Fri, 16 Oct 2026 13:05:19 -0700 Subject: [PATCH 4/7] codex-local: default LM Studio model is the 4-bit build qwen/qwen3.8-27b The 8-bit build qwen3.8-27b-mlx stays usable with -m. Homes seeded with the old default read as unset, so they pick up the new default; an explicit -m still wins. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RPCzgwU247gMtjybR5v4iR --- codex-rs/local/cli/src/overrides.rs | 2 +- codex-rs/local/models/src/engine.rs | 20 +++++++++++++++++--- local/docs/plan-deltas.md | 2 +- local/skills/delegate-local/SKILL.md | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index c10e46bd4145..89d3a404fb2f 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -293,7 +293,7 @@ mod tests { &a, "model_providers.codex-local.base_url=\"http://127.0.0.1:1234/v1\"" )); - assert!(has(&a, "model=\"qwen3.8-27b-mlx\"")); + assert!(has(&a, "model=\"qwen/qwen3.8-27b\"")); assert!(!a.url_explicit); let a = applied( &["x", "--engine=lmstudio", "--base-url", "h:9", "exec"], diff --git a/codex-rs/local/models/src/engine.rs b/codex-rs/local/models/src/engine.rs index dd41dc527238..6f5d7f26c307 100644 --- a/codex-rs/local/models/src/engine.rs +++ b/codex-rs/local/models/src/engine.rs @@ -3,8 +3,13 @@ use std::fmt; use std::str::FromStr; -/// Placeholder default model for LM Studio (the default engine); change it here only. -pub const LMSTUDIO_DEFAULT_MODEL: &str = "qwen3.8-27b-mlx"; +/// Default model for LM Studio (the default engine): the 4-bit MLX build +/// `lmstudio-community/Qwen3.8-27B-MLX-4bit`. The 8-bit build stays usable with +/// `-m qwen3.8-27b-mlx`. Change it here only. +pub const LMSTUDIO_DEFAULT_MODEL: &str = "qwen/qwen3.8-27b"; + +/// Earlier LM Studio default (the 8-bit build). Homes seeded with it are treated as unset. +pub const LMSTUDIO_LEGACY_DEFAULT_MODEL: &str = "qwen3.8-27b-mlx"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Engine { @@ -49,7 +54,8 @@ impl Engine { /// Whether `model` is the built-in default model of some engine (a seed, not a choice). pub fn is_default_model(model: &str) -> bool { - Engine::ALL.iter().any(|e| e.default_model() == Some(model)) + model == LMSTUDIO_LEGACY_DEFAULT_MODEL + || Engine::ALL.iter().any(|e| e.default_model() == Some(model)) } /// `None` for llama.cpp: it serves whatever it loaded (first `/v1/models` id). @@ -104,6 +110,14 @@ pub fn resolve_engine( mod tests { use super::*; + #[test] + fn legacy_and_new_lmstudio_defaults_both_read_as_seeds() { + assert!(Engine::is_default_model("qwen3.8-27b-mlx")); + assert!(Engine::is_default_model(LMSTUDIO_DEFAULT_MODEL)); + assert_eq!(LMSTUDIO_DEFAULT_MODEL, "qwen/qwen3.8-27b"); + assert!(!Engine::is_default_model("something-else")); + } + #[test] fn parse_and_precedence() { assert_eq!("LMStudio".parse(), Ok(Engine::LmStudio)); diff --git a/local/docs/plan-deltas.md b/local/docs/plan-deltas.md index fe12cf392295..5b803704ecb9 100644 --- a/local/docs/plan-deltas.md +++ b/local/docs/plan-deltas.md @@ -13,7 +13,7 @@ Where the build differs from the approved plan. - **Models catalog:** `codex-local-models` builds its manager with `.with_provider_catalog()`, so the local engine's catalog is authoritative and no bundled gpt-* models are merged in; `ensure_loaded` lazily fetches because the initial list is empty. - **Agent threads:** seeded `[agents] max_threads` is now 6 (existing configs keep 4, never rewritten); override with `CODEX_LOCAL_MAX_AGENTS=` (positive int, beats config.toml, invalid warns and is ignored) or the `agents.max_threads` config key. - **UPSTREAM_SYNC_TOKEN:** the upstream-sync workflow needs this secret (PAT or App token with workflows scope) to push and open the PR whenever the merge touches `.github/workflows/**`; without it that run fails early, otherwise it falls back to GITHUB_TOKEN. -- **Default engine:** LM Studio (`http://127.0.0.1:1234`, placeholder model `LMSTUDIO_DEFAULT_MODEL` in `codex-local-models`, `max_threads` 7), replacing Ollama; precedence flag > env > `codex-local.toml` > default. Seeded root/model are the LM Studio defaults and read as "unset"; old Ollama-seeded homes with no explicit engine now resolve to LM Studio (use `engine = "ollama"`, `--engine ollama` or `CODEX_LOCAL_ENGINE=ollama` to keep Ollama). +- **Default engine:** LM Studio (`http://127.0.0.1:1234`, model `LMSTUDIO_DEFAULT_MODEL` = `qwen/qwen3.8-27b` (4-bit MLX; the 8-bit build stays available via `-m qwen3.8-27b-mlx`, and a home seeded with it also reads as unset) in `codex-local-models`, `max_threads` 7), replacing Ollama; precedence flag > env > `codex-local.toml` > default. Seeded root/model are the LM Studio defaults and read as "unset"; old Ollama-seeded homes with no explicit engine now resolve to LM Studio (use `engine = "ollama"`, `--engine ollama` or `CODEX_LOCAL_ENGINE=ollama` to keep Ollama). - **Local web search:** Ollama fails the built-in server-side `web_search` tool, so config seeds `web_search = "disabled"` (also forced via `-c` on every run) and `[mcp_servers.web-search]` runs `uvx duckduckgo-mcp-server==0.7.0` (search + fetch_content, no key); `sync-config` keeps that entry; install/verify warn if `uvx` is missing. - **No app-server daemon:** upstream auto-starts/discovers a daemon for the TUI that needs a full `codex-package.json` package (a bare binary fails with "no complete local package") and would bypass in-process registrations; codex-local injects `--no-daemon` (plain TUI/resume/fork, unless `--remote`) plus `-c features.daemon_auto_start=false`, and seeds that feature off; stock is untouched. diff --git a/local/skills/delegate-local/SKILL.md b/local/skills/delegate-local/SKILL.md index 965e794f143e..9a9c3be6200c 100644 --- a/local/skills/delegate-local/SKILL.md +++ b/local/skills/delegate-local/SKILL.md @@ -20,7 +20,7 @@ reliable ONLY on narrow, fully-specified, single-shot units paired with a cheap ## Setup (zero-config) `codex-local` is installed beside stock `codex` (`local/scripts/install.py`). It defaults to -engine LM Studio, model `qwen3.8-27b-mlx` (placeholder) on `http://127.0.0.1:1234` (PAIR's LM Studio facade). Ollama (`qwen3.8:27b`, port 11434) and llama.cpp are one flag away. Overrides: +engine LM Studio, model `qwen/qwen3.8-27b` (4-bit MLX; the 8-bit build is `-m qwen3.8-27b-mlx`) on `http://127.0.0.1:1234` (PAIR's LM Studio facade). Ollama (`qwen3.8:27b`, port 11434) and llama.cpp are one flag away. Overrides: `-m MODEL`, `--base-url URL` (alias `--ollama-url`), `--engine ollama|lmstudio|llamacpp`, env `CODEX_LOCAL_MODEL` / `CODEX_LOCAL_URL` / `CODEX_LOCAL_OLLAMA_URL` / `OLLAMA_HOST` / `CODEX_LOCAL_ENGINE`. The engine can also be set in codex-local's config.toml From 78a8cff6ddc8842e0e43afc7a2f6dbc139fbb839 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Fri, 16 Oct 2026 13:13:50 -0700 Subject: [PATCH 5/7] codex-local: Ollama-specific URL inputs imply the Ollama engine --ollama-url, CODEX_LOCAL_OLLAMA_URL and OLLAMA_HOST select Ollama (and its default model) when no engine is set explicitly; --base-url/CODEX_LOCAL_URL do not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RPCzgwU247gMtjybR5v4iR --- codex-rs/local/cli/src/overrides.rs | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index 89d3a404fb2f..123d1e2ca125 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -50,6 +50,8 @@ pub fn apply_overrides( ) -> Result { let mut rest = Vec::with_capacity(args.len()); let mut flag_url: Option = None; + // `--ollama-url` is an alias of `--base-url`, but only it implies the Ollama engine. + let mut flag_url_is_ollama = false; let mut flag_engine: Option = None; let mut has_model_flag = false; let mut flag_model: Option = None; @@ -65,6 +67,7 @@ pub fn apply_overrides( if s == "--" { after_dd = true; } else if s == "--ollama-url" || s == "--base-url" { + flag_url_is_ollama = s == "--ollama-url"; match it.next() { Some(v) => flag_url = Some(v.to_string_lossy().into_owned()), None => bail!("{s} requires a value"), @@ -74,6 +77,7 @@ pub fn apply_overrides( .strip_prefix("--ollama-url=") .or_else(|| s.strip_prefix("--base-url=")) { + flag_url_is_ollama = s.starts_with("--ollama-url="); flag_url = Some(v.to_string()); continue; } else if s == "--engine" { @@ -105,12 +109,22 @@ pub fn apply_overrides( rest.push(a); } - let (engine, engine_explicit) = resolve_engine( + let (mut engine, mut engine_explicit) = resolve_engine( flag_engine.as_deref(), env("CODEX_LOCAL_ENGINE").as_deref(), config_engine.as_deref(), ) .map_err(anyhow::Error::msg)?; + // An Ollama-specific URL input implies Ollama unless an engine was set explicitly + // (the generic `--base-url`/`CODEX_LOCAL_URL` imply nothing). + if !engine_explicit + && (flag_url_is_ollama + || env("CODEX_LOCAL_OLLAMA_URL").is_some() + || env("OLLAMA_HOST").is_some()) + { + engine = Engine::Ollama; + engine_explicit = true; + } let explicit_root = match flag_url { Some(u) => Some(resolve_ollama_root(&u)?), None => env_ollama_root(env)?, @@ -229,6 +243,23 @@ mod tests { a.argv.iter().any(|s| s == kv) } + #[test] + fn ollama_url_inputs_imply_ollama_engine() { + let a = applied(&["x", "--ollama-url", "h:1"], &[], None); + assert_eq!(a.engine, Engine::Ollama); + assert!(has(&a, "model=\"qwen3.8:27b\"")); + let a = applied(&["x"], &[("OLLAMA_HOST", "h:1")], None); + assert_eq!(a.engine, Engine::Ollama); + let a = applied( + &["x", "--engine", "lmstudio", "--ollama-url", "h:1"], + &[], + None, + ); + assert_eq!(a.engine, Engine::LmStudio); + let a = applied(&["x", "--base-url", "h:1"], &[], None); + assert_eq!(a.engine, Engine::LmStudio); + } + #[test] fn engine_precedence_flag_env_config_default() { let a = applied(&["x", "exec"], &[], None); From 4f4601994ea3e31a7e50f90d836c6a7134e48d50 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Fri, 16 Oct 2026 13:23:58 -0700 Subject: [PATCH 6/7] codex-local: infer Ollama only when the winning URL came from an Ollama-specific input Co-Authored-By: Claude Opus 5.5 --- codex-rs/local/cli/src/overrides.rs | 41 +++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index 123d1e2ca125..cf363226bd88 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -115,20 +115,25 @@ pub fn apply_overrides( config_engine.as_deref(), ) .map_err(anyhow::Error::msg)?; - // An Ollama-specific URL input implies Ollama unless an engine was set explicitly - // (the generic `--base-url`/`CODEX_LOCAL_URL` imply nothing). - if !engine_explicit - && (flag_url_is_ollama - || env("CODEX_LOCAL_OLLAMA_URL").is_some() - || env("OLLAMA_HOST").is_some()) - { + // The effective (winning) URL input and whether it is Ollama-specific. Only a winning + // Ollama-specific input (`--ollama-url`, `CODEX_LOCAL_OLLAMA_URL`, `OLLAMA_HOST`) implies + // Ollama; the generic `--base-url`/`CODEX_LOCAL_URL` imply nothing. + let (explicit_root, url_is_ollama) = match flag_url { + Some(u) => (Some(resolve_ollama_root(&u)?), flag_url_is_ollama), + None => { + let won = ["CODEX_LOCAL_URL", "CODEX_LOCAL_OLLAMA_URL", "OLLAMA_HOST"] + .into_iter() + .find(|k| env(k).is_some()); + ( + env_ollama_root(env)?, + won.is_some_and(|k| k != "CODEX_LOCAL_URL"), + ) + } + }; + if !engine_explicit && url_is_ollama { engine = Engine::Ollama; engine_explicit = true; } - let explicit_root = match flag_url { - Some(u) => Some(resolve_ollama_root(&u)?), - None => env_ollama_root(env)?, - }; // Explicit URLs (flag, env, non-default config provider URL) beat engine default ports. let url_explicit = explicit_root.is_some() || config_root.is_some(); let root = explicit_root.or_else(|| config_root.is_none().then(|| engine.default_root())); @@ -258,6 +263,20 @@ mod tests { assert_eq!(a.engine, Engine::LmStudio); let a = applied(&["x", "--base-url", "h:1"], &[], None); assert_eq!(a.engine, Engine::LmStudio); + // A winning generic URL keeps the default engine despite an Ollama variable. + let a = applied( + &["x", "--base-url", "http://localhost:1234"], + &[("OLLAMA_HOST", "localhost:11434")], + None, + ); + assert_eq!(a.engine, Engine::LmStudio); + assert!(has(&a, "model=\"qwen/qwen3.8-27b\"")); + let a = applied( + &["x"], + &[("OLLAMA_HOST", "h:1"), ("CODEX_LOCAL_URL", "h:2")], + None, + ); + assert_eq!(a.engine, Engine::LmStudio); } #[test] From 15e46cc90ece6d9690ee06df09f9928e38378549 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Fri, 16 Oct 2026 15:53:35 -0700 Subject: [PATCH 7/7] codex-local: default reasoning effort to low on local engines Injected at run time (-c model_reasoning_effort="low") only when unset in -c, config.toml top level or the active profile; explicit values (including none) win. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RPCzgwU247gMtjybR5v4iR --- codex-rs/local/cli/src/lib.rs | 1 + codex-rs/local/cli/src/overrides.rs | 83 ++++++++++++++++++++++++++++ local/docs/plan-deltas.md | 1 + local/skills/delegate-local/SKILL.md | 2 +- 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/codex-rs/local/cli/src/lib.rs b/codex-rs/local/cli/src/lib.rs index a4d131bed4ee..6133fa0db792 100644 --- a/codex-rs/local/cli/src/lib.rs +++ b/codex-rs/local/cli/src/lib.rs @@ -104,6 +104,7 @@ pub fn prepare(args: Vec, env: EnvFn, user_home: Option) -> R .or_else(|| Some(DEFAULT_MODEL.to_string())); let config_root = config_root(cfg.as_ref()); let mut applied = apply_overrides(args, env, config_model, config_engine, config_root)?; + overrides::inject_default_effort(&mut applied.argv, cfg.as_ref()); let preflight_root = want_preflight.then(|| effective_root(&applied.argv, &homes, &root)); // llama.cpp serves whatever it loaded: default to the first `/v1/models` id. This is // model selection, so it runs even when the preflight itself is skipped. diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index cf363226bd88..548372726e64 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -192,6 +192,60 @@ pub fn apply_overrides( }) } +const EFFORT_KEY: &str = "model_reasoning_effort"; + +/// True when a reasoning effort is set anywhere codex-local can see: a `-c`/`--config` +/// override in `args`, or the user's config.toml (top level, or the active profile, chosen +/// by `-p`/`--profile` else the top-level `profile`). Any value counts, including `"none"`. +pub(crate) fn reasoning_effort_set(args: &[OsString], cfg: Option<&toml::Table>) -> bool { + let strs: Vec = args + .iter() + .take_while(|a| *a != "--") + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let mut profile: Option = None; + for (i, s) in strs.iter().enumerate() { + let next = strs.get(i + 1).map(String::as_str).unwrap_or(""); + let kv = match s.as_str() { + "-c" | "--config" => next, + _ => s + .strip_prefix("--config=") + .or_else(|| s.strip_prefix("-c")) + .unwrap_or(""), + }; + if kv.trim_start().starts_with(EFFORT_KEY) && kv.contains('=') { + return true; + } + if s == "-p" || s == "--profile" { + profile = Some(next.to_string()); + } else if let Some(v) = s.strip_prefix("--profile=") { + profile = Some(v.to_string()); + } + } + let Some(cfg) = cfg else { return false }; + if cfg.contains_key(EFFORT_KEY) { + return true; + } + profile + .or_else(|| { + cfg.get("profile") + .and_then(|p| p.as_str()) + .map(str::to_string) + }) + .and_then(|p| cfg.get("profiles")?.get(&p)?.get(EFFORT_KEY).cloned()) + .is_some() +} + +/// Local engines think at their own (long) default unless the request carries +/// `reasoning.effort`, and upstream omits the field for "none". Default to `low` +/// at run time (never seeded) unless the user set a value anywhere. +pub(crate) fn inject_default_effort(argv: &mut Vec, cfg: Option<&toml::Table>) { + if !reasoning_effort_set(argv, cfg) && !argv.is_empty() { + argv.insert(1, "-c".into()); + argv.insert(2, format!("{EFFORT_KEY}=\"low\"").into()); + } +} + #[cfg(test)] mod tests { use super::*; @@ -498,4 +552,33 @@ mod tests { ] ); } + + #[test] + fn default_effort_low_unless_set_anywhere() { + let inject = |args: &[&str], cfg: &str| { + let mut v: Vec = args.iter().map(OsString::from).collect(); + let t = cfg.parse::().unwrap(); + inject_default_effort(&mut v, Some(&t)); + v.iter() + .map(|s| s.to_string_lossy().into_owned()) + .collect::>() + }; + assert_eq!( + inject(&["x", "exec"], ""), + ["x", "-c", "model_reasoning_effort=\"low\"", "exec"] + ); + for (a, c) in [ + ( + &["x", "-c", "model_reasoning_effort=\"high\"", "exec"][..], + "", + ), + (&["x", "exec"][..], "model_reasoning_effort = \"none\""), + ( + &["x", "exec"][..], + "profile=\"p\"\n[profiles.p]\nmodel_reasoning_effort=\"high\"", + ), + ] { + assert_eq!(inject(a, c).len(), a.len(), "{a:?} {c}"); + } + } } diff --git a/local/docs/plan-deltas.md b/local/docs/plan-deltas.md index 5b803704ecb9..1c0ddeb78af1 100644 --- a/local/docs/plan-deltas.md +++ b/local/docs/plan-deltas.md @@ -21,3 +21,4 @@ Where the build differs from the approved plan. - Stock resumes a codex-local session with `codex resume -c model_provider=openai`; `codex exec resume ` needs no flag. - codex-local always continues on its own provider and model: it injects `-c model_provider="codex-local"` and `-c model=` (unless `-m` is given), because upstream only overrides a resumed rollout's provider/model when a CLI layer sets them. +- **Reasoning effort:** default reasoning effort is low on local engines; override with -c model_reasoning_effort=... (injected at run time only when unset in `-c`, config.toml top level or the active profile; `"none"` is respected; never seeded into config.toml). diff --git a/local/skills/delegate-local/SKILL.md b/local/skills/delegate-local/SKILL.md index 9a9c3be6200c..0374ac1774bf 100644 --- a/local/skills/delegate-local/SKILL.md +++ b/local/skills/delegate-local/SKILL.md @@ -24,7 +24,7 @@ engine LM Studio, model `qwen/qwen3.8-27b` (4-bit MLX; the 8-bit build is `-m qw `-m MODEL`, `--base-url URL` (alias `--ollama-url`), `--engine ollama|lmstudio|llamacpp`, env `CODEX_LOCAL_MODEL` / `CODEX_LOCAL_URL` / `CODEX_LOCAL_OLLAMA_URL` / `OLLAMA_HOST` / `CODEX_LOCAL_ENGINE`. The engine can also be set in codex-local's config.toml -(`[local]` table, `engine = "lmstudio"`); precedence is flag > env > config > `lmstudio`. Homes seeded with Ollama's URL/model and no explicit engine now resolve to LM Studio; keep Ollama with `engine = "ollama"`, `--engine ollama` or `CODEX_LOCAL_ENGINE=ollama`. No profiles, tunnels, fleet files or node locks. Check with +(`[local]` table, `engine = "lmstudio"`); precedence is flag > env > config > `lmstudio`. Homes seeded with Ollama's URL/model and no explicit engine now resolve to LM Studio; keep Ollama with `engine = "ollama"`, `--engine ollama` or `CODEX_LOCAL_ENGINE=ollama`. No profiles, tunnels, fleet files or node locks. Check with Default reasoning effort is low on local engines; override with -c model_reasoning_effort=... `verify_install.py` (full) if a unit fails oddly. ## Green / Yellow / Red decisioning