Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion codex-rs/local/cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ fn main() {
}

fn git(dir: &str, args: &[&str]) -> Option<String> {
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;
}
Expand Down
171 changes: 150 additions & 21 deletions codex-rs/local/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

mod homes;
mod links;
mod local_cfg;
mod overrides;
mod preflight;
mod seed;
Expand All @@ -14,19 +15,22 @@ 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";
/// 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";

/// `<upstream-version>+local.<sha7>`; the sha comes from `build.rs`.
Expand Down Expand Up @@ -59,8 +63,11 @@ pub enum Action {
Run {
argv: Vec<OsString>,
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<String>,
/// Model to look for on the other engines' default ports when the engine was
/// never chosen explicitly; `None` skips the probe.
probe_model: Option<String>,
},
}

Expand All @@ -72,25 +79,58 @@ pub fn prepare(args: Vec<OsString>, env: EnvFn, user_home: Option<PathBuf>) -> 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.
// 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::<Engine>().ok())
.unwrap_or_default();
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") {
sync_config(&homes)?;
return Ok(Action::SyncConfig);
}
let args_snapshot = args.clone();
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::<toml::Table>().ok())
.and_then(|t| t.parse::<toml::Table>().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));
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.
if applied.engine == Engine::LlamaCpp
&& applied.effective_model.is_none()
&& preflight::uses_model(&args_snapshot)
{
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.effective_model.clone())
.flatten()
.filter(|_| want_preflight);
Ok(Action::Run {
argv,
argv: applied.argv,
codex_home: homes.local,
preflight_root,
probe_model,
})
}

Expand All @@ -106,19 +146,33 @@ 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::<toml::Table>().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::<toml::Table>().ok())
.as_ref(),
)
.unwrap_or_else(|| fallback.to_string())
}

/// 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<String> {
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();
(!Engine::is_default_root(&root)).then_some(root)
}

pub(crate) fn env_ollama_root(env: EnvFn) -> Result<Option<String>> {
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);
}
Expand Down Expand Up @@ -185,7 +239,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(),
Expand Down Expand Up @@ -216,4 +270,79 @@ mod tests {
1
);
}

fn run_env(tmp: &std::path::Path, extra: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
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<String> {
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::<Vec<_>>();
let act = prepare(a(&["x", "--engine", "ollama", "exec"]), &env, None).unwrap();
// 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", "llamacpp"),
("CODEX_LOCAL_SKIP_PREFLIGHT", "1"),
],
);
let cfg = fs::read_to_string(tmp.path().join("local/config.toml")).unwrap();
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(":8080/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::<Vec<_>>();
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}");
}
}
55 changes: 55 additions & 0 deletions codex-rs/local/cli/src/local_cfg.rs
Original file line number Diff line number Diff line change
@@ -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 lmstudio, 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<Option<String>> {
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::<DocumentMut>()
&& 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))
}
22 changes: 15 additions & 7 deletions codex-rs/local/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand All @@ -61,13 +61,21 @@ 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)
{
eprintln!("{}", codex_local_cli::preflight_failure_message(&root, &e));
std::process::exit(1);
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) };
Expand Down
Loading