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
2 changes: 2 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/local/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ codex-local-models = { path = "../models" }
codex-local-resume = { path = "../resume" }
dirs = { workspace = true }
toml_edit = { workspace = true }
reqwest = { workspace = true }
codex-utils-process = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
Expand Down
31 changes: 31 additions & 0 deletions codex-rs/local/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
mod homes;
mod links;
mod overrides;
mod preflight;
mod seed;
mod sync;
mod url;
Expand All @@ -18,6 +19,8 @@ pub use homes::resolve_homes;
pub use links::ensure_home;
pub use links::refresh_skills;
pub use overrides::apply_overrides;
pub use preflight::check as preflight_check;
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;
Expand Down Expand Up @@ -56,6 +59,8 @@ pub enum Action {
Run {
argv: Vec<OsString>,
codex_home: PathBuf,
/// Ollama root to probe before delegating; `None` skips the preflight.
preflight_root: Option<String>,
},
}

Expand All @@ -74,13 +79,39 @@ pub fn prepare(args: Vec<OsString>, env: EnvFn, user_home: Option<PathBuf>) -> R
sync_config(&homes)?;
return Ok(Action::SyncConfig);
}
let want_preflight = preflight::needs_preflight(&args, env);
let argv = apply_overrides(args, env)?;
let preflight_root = want_preflight.then(|| effective_root(&argv, &homes, &root));
Ok(Action::Run {
argv,
codex_home: homes.local,
preflight_root,
})
}

/// Root that upstream will end up using: the injected base_url override, else the
/// seeded config's base_url, else the env/built-in root.
fn effective_root(argv: &[OsString], homes: &Homes, fallback: &str) -> String {
let key = format!("model_providers.{PROVIDER_ID}.base_url=");
let strip = |u: &str| u.trim_end_matches('/').trim_end_matches("/v1").to_string();
for w in argv.windows(2) {
if w[0] == "-c"
&& let Some(v) = w[1].to_string_lossy().strip_prefix(&key)
{
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)
})
.unwrap_or_else(|| fallback.to_string())
}

pub(crate) fn env_ollama_root(env: EnvFn) -> Result<Option<String>> {
for key in ["CODEX_LOCAL_OLLAMA_URL", "OLLAMA_HOST"] {
if let Some(v) = env(key) {
Expand Down
20 changes: 17 additions & 3 deletions codex-rs/local/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,29 @@ include!("../../../cli/src/main.rs");
fn main() -> anyhow::Result<()> {
codex_build_info::initialize!();
let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
let action = codex_local_cli::prepare(args, &codex_local_cli::process_env, codex_local_cli::user_home_dir())?;
let (argv, codex_home) = match action {
let action = codex_local_cli::prepare(
args,
&codex_local_cli::process_env,
codex_local_cli::user_home_dir(),
)?;
let (argv, codex_home, preflight_root) = match action {
codex_local_cli::Action::Version(v) => {
println!("{v}");
return Ok(());
}
codex_local_cli::Action::SyncConfig => return Ok(()),
codex_local_cli::Action::Run { argv, codex_home } => (argv, codex_home),
codex_local_cli::Action::Run {
argv,
codex_home,
preflight_root,
} => (argv, codex_home, preflight_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));
std::process::exit(1);
}
// SAFETY: still single-threaded; no runtime or worker threads exist yet.
unsafe { std::env::set_var("CODEX_HOME", &codex_home) };
codex_local_models::register();
Expand Down
67 changes: 63 additions & 4 deletions codex-rs/local/cli/src/overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use crate::PROVIDER_ID;
use crate::env_ollama_root;
use crate::resolve_ollama_root;

pub(crate) const MAX_RETRIES: u64 = 2;

/// Strip `--ollama-url` (ours, unknown to upstream) and inject `-c` overrides right
/// after argv[0] (root-level, so they reach every subcommand).
///
Expand Down Expand Up @@ -63,6 +65,21 @@ pub fn apply_overrides(args: Vec<OsString>, env: EnvFn) -> Result<Vec<OsString>>
let v = toml::Value::String(format!("{root}/v1"));
push_c(format!("model_providers.{PROVIDER_ID}.base_url={v}"));
}
// Bound upstream's retry loops so an outage ends in an error, not an endless
// "Reconnecting..." (config seeding only covers new homes).
push_c(format!(
"model_providers.{PROVIDER_ID}.request_max_retries={MAX_RETRIES}"
));
push_c(format!(
"model_providers.{PROVIDER_ID}.stream_max_retries={MAX_RETRIES}"
));
// `CODEX_LOCAL_MAX_AGENTS` beats the seeded `[agents] max_threads` in config.toml.
if let Some(raw) = env("CODEX_LOCAL_MAX_AGENTS") {
match raw.trim().parse::<u64>() {
Ok(n) if n > 0 => push_c(format!("agents.max_threads={n}")),
_ => eprintln!("codex-local: ignoring invalid CODEX_LOCAL_MAX_AGENTS={raw:?}"),
}
}
out.extend(rest);
Ok(out)
}
Expand All @@ -85,11 +102,34 @@ mod tests {
.collect()
}

const RQ: &str = "model_providers.codex-local.request_max_retries=2";
const ST: &str = "model_providers.codex-local.stream_max_retries=2";
const URL_KEY: &str = "model_providers.codex-local.base_url";

#[test]
fn nothing_set_changes_nothing() {
assert_eq!(run(&["x", "exec", "hi"], &[]), ["x", "exec", "hi"]);
let want = [
"x",
"-c",
"model_providers.codex-local.request_max_retries=2",
"-c",
"model_providers.codex-local.stream_max_retries=2",
"exec",
"hi",
];
assert_eq!(run(&["x", "exec", "hi"], &[]), want);
}

#[test]
fn max_agents_env_valid_invalid_unset() {
let k = "CODEX_LOCAL_MAX_AGENTS";
let ma = "agents.max_threads=8";
assert_eq!(run(&["x", "exec"], &[(k, "8")])[6], ma);
assert_eq!(run(&["x", "exec"], &[(k, "8")]).len(), 8);
for bad in ["0", "-1", "abc", ""] {
assert_eq!(run(&["x", "exec"], &[(k, bad)]).len(), 6);
}
assert_eq!(run(&["x", "exec"], &[]).len(), 6);
}

#[test]
Expand All @@ -98,7 +138,7 @@ mod tests {
let want = |u: &str| format!("{URL_KEY}=\"{u}/v1\"");
assert_eq!(
run(&["x", "--ollama-url", "http://f:3", "exec"], &env),
["x", "-c", &want("http://f:3"), "exec"]
["x", "-c", &want("http://f:3"), "-c", RQ, "-c", ST, "exec"]
);
assert_eq!(run(&["x", "exec"], &env)[2], want("http://h1:1"));
assert_eq!(
Expand All @@ -113,11 +153,30 @@ mod tests {
assert_eq!(run(&["x", "exec"], &env)[2], "model=\"envm\"");
assert_eq!(
run(&["x", "-m", "flagm", "exec"], &env),
["x", "-m", "flagm", "exec"]
[
"x",
"-c",
"model_providers.codex-local.request_max_retries=2",
"-c",
"model_providers.codex-local.stream_max_retries=2",
"-m",
"flagm",
"exec"
]
);
assert_eq!(
run(&["x", "exec", "--", "--ollama-url", "-m"], &[]),
["x", "exec", "--", "--ollama-url", "-m"]
[
"x",
"-c",
"model_providers.codex-local.request_max_retries=2",
"-c",
"model_providers.codex-local.stream_max_retries=2",
"exec",
"--",
"--ollama-url",
"-m"
]
);
}
}
151 changes: 151 additions & 0 deletions codex-rs/local/cli/src/preflight.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//! Fail-fast check that Ollama answers before upstream starts its retry loops.

use std::ffi::OsString;
use std::time::Duration;

use crate::EnvFn;

const TIMEOUT: Duration = Duration::from_secs(3);

/// Subcommands that never talk to the model.
const SKIP: &[&str] = &[
"sync-config",
"login",
"logout",
"completion",
"debug",
"mcp",
"mcp-server",
"plugin",
"update",
"doctor",
"sandbox",
"execpolicy",
"apply",
"a",
"app-server",
"remote-control",
"app",
"archive",
"unarchive",
"delete",
"migrate-rollouts",
"queue",
"tcp-tunnel",
"agents",
"help",
"features",
"cloud",
"responses-api-proxy",
"stdio-to-uds",
];

/// Flags (of the root command) that consume the next argv element.
const VALUE_FLAGS: &[&str] = &[
"-c",
"--config",
"-m",
"--model",
"-p",
"--profile",
"-C",
"--cd",
"-i",
"--image",
"-s",
"--sandbox",
"-a",
"--ask-for-approval",
"--ollama-url",
"--local-provider",
"--enable",
"--disable",
"--remote",
"--remote-auth-token-env",
];

/// Whether this invocation needs a reachable Ollama (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;
}
let mut it = args.iter().skip(1).map(|a| a.to_string_lossy());
while let Some(a) = it.next() {
match a.as_ref() {
"--" => return true,
"-h" | "--help" | "-V" | "--version" => return false,
f if VALUE_FLAGS.contains(&f) => {
it.next();
}
f if f.starts_with('-') => {}
sub => return !SKIP.contains(&sub),
}
}
true
}

/// `GET <root>/api/tags`; the error is a short human string.
pub fn check(root: &str) -> Result<(), String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let r = reqwest::Client::new()
.get(format!("{root}/api/tags"))
.timeout(TIMEOUT)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"timed out after 3s".to_string()
} else if e.is_connect() {
"connection refused or unreachable".to_string()
} else {
e.without_url().to_string()
}
})?;
// Any HTTP answer means something is listening.
drop(r);
Ok(())
})
}

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

#[cfg(test)]
mod tests {
use super::*;

fn a(v: &[&str]) -> Vec<OsString> {
v.iter().map(OsString::from).collect()
}

#[test]
fn allowlist_decision() {
let none = |_: &str| None;
assert!(needs_preflight(&a(&["x"]), &none));
assert!(needs_preflight(&a(&["x", "exec", "hi"]), &none));
assert!(needs_preflight(&a(&["x", "-m", "login", "exec"]), &none));
assert!(!needs_preflight(&a(&["x", "login"]), &none));
assert!(!needs_preflight(
&a(&["x", "-c", "a=b", "mcp", "list"]),
&none
));
assert!(!needs_preflight(&a(&["x", "--help"]), &none));
let skip = |k: &str| (k == "CODEX_LOCAL_SKIP_PREFLIGHT").then(|| "1".to_string());
assert!(!needs_preflight(&a(&["x", "exec"]), &skip));
}

#[test]
fn closed_port_fails_fast() {
let t = std::time::Instant::now();
let e = check("http://127.0.0.1:1").unwrap_err();
assert!(t.elapsed() < Duration::from_secs(4), "{e}");
}
}
Loading