Skip to content

Commit 6c4441f

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 2832fd1 + 851667f commit 6c4441f

31 files changed

Lines changed: 3349 additions & 24 deletions

src-tauri/resources/codex/bundled-catalog.json

Lines changed: 600 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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

Comments
 (0)