|
| 1 | +//! Runtime source for codex's official model catalog. |
| 2 | +//! |
| 3 | +//! Because `model_catalog_json` is a whole-table replace, codeg must reproduce |
| 4 | +//! codex's own catalog inside the generated file — so it has to match the codex |
| 5 | +//! codeg **actually launches**. That codex is the one **nested** under the |
| 6 | +//! pinned `codex-acp` npm package (`.../codex-acp/node_modules/@openai/codex`), |
| 7 | +//! NOT the `codex` on PATH (often an unrelated standalone install of a different |
| 8 | +//! version, and the version that gets hoisted to the top-level `node_modules`). |
| 9 | +//! |
| 10 | +//! We resolve it with node's own nearest-`node_modules`-first resolution |
| 11 | +//! (`require.resolve('@openai/codex/bin/codex.js', {paths:[<codex-acp dir>]})`), |
| 12 | +//! run `codex debug models --bundled`, and cache the JSON on disk with a |
| 13 | +//! live → cache → bundled-snapshot fallback chain, mirroring |
| 14 | +//! [`crate::acp::opencode_catalog`]. Infallible by construction: the compiled-in |
| 15 | +//! snapshot ([`crate::acp::codex_model_catalog::bundled_snapshot_models`]) |
| 16 | +//! guarantees a result offline. |
| 17 | +//! |
| 18 | +//! The cache lives under [`crate::paths::codeg_home_dir`] (not the app data dir) |
| 19 | +//! so both the async editor path and the **synchronous** config-write paths can |
| 20 | +//! reach it with zero `data_dir` threading. |
| 21 | +
|
| 22 | +use std::path::{Path, PathBuf}; |
| 23 | +use std::time::{Duration, SystemTime}; |
| 24 | + |
| 25 | +use serde_json::Value; |
| 26 | + |
| 27 | +/// On-disk cache freshness window — matches the OpenCode catalog cache. |
| 28 | +const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); |
| 29 | +/// Upper bound on each codex subprocess (node resolve + `debug models`). |
| 30 | +const CODEX_TIMEOUT: Duration = Duration::from_secs(15); |
| 31 | + |
| 32 | +fn cache_path() -> PathBuf { |
| 33 | + crate::paths::codeg_home_dir() |
| 34 | + .join("cache") |
| 35 | + .join("codex") |
| 36 | + .join("bundled-catalog.json") |
| 37 | +} |
| 38 | + |
| 39 | +/// Extract the `models` array from a `{"models":[...]}` document. |
| 40 | +fn parse_models(text: &str) -> Option<Vec<Value>> { |
| 41 | + serde_json::from_str::<Value>(text) |
| 42 | + .ok()? |
| 43 | + .get("models")? |
| 44 | + .as_array() |
| 45 | + .cloned() |
| 46 | +} |
| 47 | + |
| 48 | +fn read_cache(require_fresh: bool) -> Option<Vec<Value>> { |
| 49 | + let path = cache_path(); |
| 50 | + let metadata = std::fs::metadata(&path).ok()?; |
| 51 | + if require_fresh { |
| 52 | + let age = metadata |
| 53 | + .modified() |
| 54 | + .ok() |
| 55 | + .and_then(|m| SystemTime::now().duration_since(m).ok())?; |
| 56 | + if age > CACHE_TTL { |
| 57 | + return None; |
| 58 | + } |
| 59 | + } |
| 60 | + let text = std::fs::read_to_string(&path).ok()?; |
| 61 | + parse_models(&text).filter(|m| !m.is_empty()) |
| 62 | +} |
| 63 | + |
| 64 | +fn write_cache(models: &[Value]) { |
| 65 | + let path = cache_path(); |
| 66 | + if let Some(parent) = path.parent() { |
| 67 | + if std::fs::create_dir_all(parent).is_err() { |
| 68 | + return; |
| 69 | + } |
| 70 | + } |
| 71 | + if let Ok(text) = serde_json::to_string(&serde_json::json!({ "models": models })) { |
| 72 | + let _ = std::fs::write(&path, text); |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +/// The codex-acp package directory under an npm prefix, where the nested |
| 77 | +/// `@openai/codex` codex-acp actually drives lives. |
| 78 | +fn codex_acp_dir(prefix: &Path) -> PathBuf { |
| 79 | + let base = if cfg!(windows) { |
| 80 | + prefix.join("node_modules") |
| 81 | + } else { |
| 82 | + prefix.join("lib").join("node_modules") |
| 83 | + }; |
| 84 | + base.join("@agentclientprotocol").join("codex-acp") |
| 85 | +} |
| 86 | + |
| 87 | +/// Candidate codex-acp package dirs: the global npm prefix and codeg's user |
| 88 | +/// prefix (`~/.codeg/npm-global`, used when a global install hit EACCES). |
| 89 | +async fn codex_acp_dirs() -> Vec<PathBuf> { |
| 90 | + let mut dirs = Vec::new(); |
| 91 | + if let Some(prefix) = crate::commands::acp::cached_npm_global_prefix().await { |
| 92 | + dirs.push(codex_acp_dir(&prefix)); |
| 93 | + } |
| 94 | + if let Some(prefix) = crate::process::user_npm_prefix() { |
| 95 | + dirs.push(codex_acp_dir(&prefix)); |
| 96 | + } |
| 97 | + dirs |
| 98 | +} |
| 99 | + |
| 100 | +/// Resolve the nested `@openai/codex/bin/codex.js` from a codex-acp package dir |
| 101 | +/// using node's own resolver (nearest `node_modules` first) so we get the |
| 102 | +/// version codex-acp uses, not a hoisted/PATH one. |
| 103 | +async fn resolve_codex_js(acp_dir: &Path, node: &Path) -> Option<PathBuf> { |
| 104 | + let mut cmd = crate::process::tokio_command(node); |
| 105 | + cmd.arg("-e") |
| 106 | + .arg("process.stdout.write(require.resolve('@openai/codex/bin/codex.js',{paths:[process.argv[1]]}))") |
| 107 | + .arg(acp_dir) |
| 108 | + .kill_on_drop(true); |
| 109 | + let output = tokio::time::timeout(CODEX_TIMEOUT, cmd.output()) |
| 110 | + .await |
| 111 | + .ok()? |
| 112 | + .ok()?; |
| 113 | + if !output.status.success() { |
| 114 | + return None; |
| 115 | + } |
| 116 | + let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 117 | + if resolved.is_empty() { |
| 118 | + return None; |
| 119 | + } |
| 120 | + let path = PathBuf::from(resolved); |
| 121 | + path.exists().then_some(path) |
| 122 | +} |
| 123 | + |
| 124 | +/// Run the nested codex's `debug models --bundled` and return its `models`. |
| 125 | +async fn fetch_live() -> Option<Vec<Value>> { |
| 126 | + let node = which::which("node").ok()?; |
| 127 | + for acp_dir in codex_acp_dirs().await { |
| 128 | + if !acp_dir.exists() { |
| 129 | + continue; |
| 130 | + } |
| 131 | + let Some(codex_js) = resolve_codex_js(&acp_dir, &node).await else { |
| 132 | + continue; |
| 133 | + }; |
| 134 | + let mut cmd = crate::process::tokio_command(&node); |
| 135 | + cmd.arg(&codex_js) |
| 136 | + .arg("debug") |
| 137 | + .arg("models") |
| 138 | + .arg("--bundled") |
| 139 | + .kill_on_drop(true); |
| 140 | + let Ok(Ok(output)) = tokio::time::timeout(CODEX_TIMEOUT, cmd.output()).await else { |
| 141 | + continue; |
| 142 | + }; |
| 143 | + if !output.status.success() { |
| 144 | + continue; |
| 145 | + } |
| 146 | + if let Some(models) = parse_models(&String::from_utf8_lossy(&output.stdout)) { |
| 147 | + if !models.is_empty() { |
| 148 | + return Some(models); |
| 149 | + } |
| 150 | + } |
| 151 | + } |
| 152 | + None |
| 153 | +} |
| 154 | + |
| 155 | +/// Resolve the official codex catalog with the live → cache → bundled-snapshot |
| 156 | +/// fallback chain. Infallible. Used by the editor (may spawn codex + refresh the |
| 157 | +/// cache); the write paths use the sync [`cached_or_bundled_snapshot`] instead. |
| 158 | +pub async fn runtime_catalog(force_refresh: bool) -> Vec<Value> { |
| 159 | + if !force_refresh { |
| 160 | + if let Some(fresh) = read_cache(true) { |
| 161 | + return fresh; |
| 162 | + } |
| 163 | + } |
| 164 | + if let Some(models) = fetch_live().await { |
| 165 | + write_cache(&models); |
| 166 | + return models; |
| 167 | + } |
| 168 | + read_cache(false).unwrap_or_else(crate::acp::codex_model_catalog::bundled_snapshot_models) |
| 169 | +} |
| 170 | + |
| 171 | +/// Synchronous catalog for the config-write paths: the on-disk cache (kept warm |
| 172 | +/// by the editor's [`runtime_catalog`] fetch), else the bundled snapshot. Never |
| 173 | +/// spawns a subprocess, so saving stays fast and works from sync contexts. |
| 174 | +pub fn cached_or_bundled_snapshot() -> Vec<Value> { |
| 175 | + read_cache(false).unwrap_or_else(crate::acp::codex_model_catalog::bundled_snapshot_models) |
| 176 | +} |
| 177 | + |
| 178 | +#[cfg(test)] |
| 179 | +mod tests { |
| 180 | + use super::*; |
| 181 | + |
| 182 | + #[test] |
| 183 | + fn parse_models_extracts_array() { |
| 184 | + assert_eq!( |
| 185 | + parse_models(r#"{"models":[{"slug":"a"}]}"#).unwrap().len(), |
| 186 | + 1 |
| 187 | + ); |
| 188 | + assert!(parse_models("not json").is_none()); |
| 189 | + assert!(parse_models(r#"{"nope":1}"#).is_none()); |
| 190 | + } |
| 191 | + |
| 192 | + #[test] |
| 193 | + fn cached_or_bundled_falls_back_to_snapshot() { |
| 194 | + // Whatever the cache state, the fallback guarantees a non-empty catalog |
| 195 | + // (the compiled-in snapshot), so callers never get an empty list. |
| 196 | + assert!(!cached_or_bundled_snapshot().is_empty()); |
| 197 | + } |
| 198 | +} |
0 commit comments