Skip to main content

hatchery_node/
provider_inventory.rs

1//! Provider inventory: which provider CLIs this node can run and which models each one
2//! exposes, read from the CLI itself (or its own config). Nothing is invented: a provider whose
3//! CLI does not list models reports only what its help text / config names.
4//!
5//! - codex: `codex debug models` (JSON catalog; entries with `visibility = "list"`).
6//! - grok: `grok models` (text list; `*` marks the default).
7//! - claude: the model aliases its `--help` names for `--model`, plus `model` from
8//!   `~/.claude/settings.json`.
9//! - kimi: `[models.<alias>]` and `default_model` from `~/.kimi/config.toml`.
10
11use serde::Serialize;
12use std::io::Read;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::time::{Duration, Instant};
16
17#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
18pub struct ModelInfo {
19    pub id: String,
20    #[serde(skip_serializing_if = "Vec::is_empty")]
21    pub efforts: Vec<String>,
22    #[serde(skip_serializing_if = "std::ops::Not::not")]
23    pub default: bool,
24}
25
26#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
27pub struct ProviderInfo {
28    pub provider: String,
29    /// Where the list came from (`cli`, `help`, `config`).
30    pub source: String,
31    pub models: Vec<ModelInfo>,
32}
33
34pub const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi"];
35
36fn which(bin: &str) -> Option<PathBuf> {
37    let path = std::env::var_os("PATH")?;
38    std::env::split_paths(&path).map(|d| d.join(bin)).find(|p| p.is_file())
39}
40
41/// Run `bin args` with a deadline; stdout as text (stderr dropped, no stdin).
42fn run(bin: &Path, args: &[&str], deadline: Duration) -> Option<String> {
43    let mut child = Command::new(bin).args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
44    let mut out = child.stdout.take()?;
45    let reader = std::thread::spawn(move || {
46        let mut buf = Vec::new();
47        let _ = out.by_ref().take(4 << 20).read_to_end(&mut buf);
48        buf
49    });
50    let start = Instant::now();
51    loop {
52        match child.try_wait() {
53            Ok(Some(_)) => break,
54            Ok(None) if start.elapsed() < deadline => std::thread::sleep(Duration::from_millis(50)),
55            _ => {
56                let _ = child.kill();
57                let _ = child.wait();
58                return None;
59            }
60        }
61    }
62    String::from_utf8(reader.join().ok()?).ok()
63}
64
65/// Model ids may carry a vendor path or tag (`kimi-code/k3`, `name:tag`, `x@v`); entries that
66/// fail are dropped one by one, never the whole provider.
67fn ok_id(s: &str) -> bool {
68    !s.is_empty() && s.len() <= 128 && !s.starts_with(['/', ':']) && !s.contains("..") && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.[]/:@".contains(&b))
69}
70
71pub fn parse_codex(json: &str) -> Vec<ModelInfo> {
72    let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else { return Vec::new() };
73    let list = v.get("models").and_then(|m| m.as_array()).cloned().unwrap_or_default();
74    list.iter()
75        .filter(|m| m.get("visibility").and_then(|x| x.as_str()).is_none_or(|x| x == "list"))
76        .filter_map(|m| {
77            let id = m.get("slug").or_else(|| m.get("id")).and_then(|x| x.as_str())?;
78            ok_id(id).then(|| ModelInfo {
79                id: id.to_owned(),
80                efforts: m
81                    .get("supported_reasoning_levels")
82                    .and_then(|l| l.as_array())
83                    .map(|l| l.iter().filter_map(|e| e.get("effort").and_then(|x| x.as_str())).filter(|e| ok_id(e)).map(str::to_owned).collect())
84                    .unwrap_or_default(),
85                default: false,
86            })
87        })
88        .collect()
89}
90
91pub fn parse_grok(text: &str) -> Vec<ModelInfo> {
92    text.lines()
93        .filter_map(|l| {
94            let t = l.trim_start();
95            let (default, rest) = if let Some(r) = t.strip_prefix("* ") { (true, r) } else { (false, t.strip_prefix("- ")?) };
96            let id = rest.split_whitespace().next()?;
97            ok_id(id).then(|| ModelInfo { id: id.to_owned(), efforts: Vec::new(), default })
98        })
99        .collect()
100}
101
102/// Quoted aliases in the `--model` paragraph of `claude --help`.
103pub fn parse_claude_help(help: &str) -> Vec<ModelInfo> {
104    let Some(start) = help.find("--model") else { return Vec::new() };
105    let para: String = help[start..].lines().take_while(|l| !l.trim().is_empty()).take(8).enumerate()
106        .filter(|(i, l)| *i == 0 || !l.trim_start().starts_with("--"))
107        .map(|(_, l)| l)
108        .collect::<Vec<_>>()
109        .join(" ");
110    let mut out: Vec<ModelInfo> = Vec::new();
111    for (i, part) in para.split('\'').enumerate() {
112        if i % 2 == 1 && ok_id(part) && !out.iter().any(|m| m.id == part) {
113            out.push(ModelInfo { id: part.to_owned(), efforts: Vec::new(), default: false });
114        }
115    }
116    out
117}
118
119pub fn parse_kimi_config(toml_text: &str) -> Vec<ModelInfo> {
120    let mut out = Vec::new();
121    let mut default = None;
122    for line in toml_text.lines() {
123        let l = line.trim();
124        if let Some(rest) = l.strip_prefix("[models.").and_then(|r| r.strip_suffix(']')) {
125            let id = rest.trim_matches('"');
126            if ok_id(id) {
127                out.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: false });
128            }
129        } else if let Some(v) = l.strip_prefix("default_model") {
130            default = v.trim_start().strip_prefix('=').map(|v| v.trim().trim_matches('"').to_owned());
131        }
132    }
133    for m in &mut out {
134        m.default = default.as_deref() == Some(m.id.as_str());
135    }
136    out
137}
138
139fn home() -> Option<PathBuf> {
140    std::env::var_os("HOME").map(PathBuf::from)
141}
142
143/// Detect one provider; `None` when its CLI is not on PATH.
144pub fn detect(provider: &str) -> Option<ProviderInfo> {
145    let deadline = Duration::from_secs(20);
146    let (bin, source, models) = match provider {
147        "codex" => {
148            let bin = which("codex")?;
149            let m = run(&bin, &["debug", "models"], deadline).map(|s| parse_codex(&s)).unwrap_or_default();
150            (bin, "cli", m)
151        }
152        "grok" => {
153            let bin = which("grok")?;
154            let m = run(&bin, &["models"], deadline).map(|s| parse_grok(&s)).unwrap_or_default();
155            (bin, "cli", m)
156        }
157        "claude" => {
158            let bin = which("claude")?;
159            let mut m = run(&bin, &["--help"], deadline).map(|s| parse_claude_help(&s)).unwrap_or_default();
160            if let Some(set) = home().and_then(|h| std::fs::read(h.join(".claude/settings.json")).ok()).and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok()) {
161                if let Some(id) = set.get("model").and_then(|x| x.as_str()).filter(|s| ok_id(s)) {
162                    match m.iter_mut().find(|x| x.id == id) {
163                        Some(x) => x.default = true,
164                        None => m.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: true }),
165                    }
166                }
167            }
168            (bin, "help", m)
169        }
170        "kimi" => {
171            let bin = which("kimi").filter(|b| usable(b))?;
172            let m = home().and_then(|h| std::fs::read_to_string(h.join(".kimi/config.toml")).ok()).map(|s| parse_kimi_config(&s)).unwrap_or_default();
173            (bin, "config", m)
174        }
175        _ => return None,
176    };
177    let _ = bin;
178    Some(ProviderInfo { provider: provider.to_owned(), source: source.to_owned(), models })
179}
180
181/// All providers whose CLI this node can run.
182pub fn detect_all() -> Vec<ProviderInfo> {
183    PROVIDERS.iter().filter_map(|p| detect(p)).collect()
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn parses_codex_catalog() {
192        let j = r#"{"models":[{"slug":"m-a","visibility":"list","supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}]},{"slug":"m-hidden","visibility":"hide"},{"slug":"bad slug","visibility":"list"}]}"#;
193        let m = parse_codex(j);
194        assert_eq!(m.len(), 1);
195        assert_eq!(m[0].id, "m-a");
196        assert_eq!(m[0].efforts, ["low", "high"]);
197        assert!(parse_codex("nope").is_empty());
198    }
199
200    #[test]
201    fn parses_grok_list() {
202        let m = parse_grok("You are not authenticated.\n\nDefault model: g-1\n\nAvailable models:\n  * g-1 (default)\n  - g-0\n");
203        assert_eq!(m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(), [("g-1", true), ("g-0", false)]);
204    }
205
206    #[test]
207    fn parses_claude_help_aliases() {
208        let h = "  --mcp-config <c>    x\n  --model <model>     Model for the current session. Provide\n                      an alias for the latest model (e.g.\n                      'a1', 'b2', or 'c3') or a\n                      model's full name.\n  --name <n>          y 'zz'\n";
209        let m = parse_claude_help(h);
210        assert_eq!(m.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(), ["a1", "b2", "c3"]);
211    }
212
213    #[test]
214    fn parses_kimi_config() {
215        let m = parse_kimi_config("default_model = \"k2\"\n[models.k1]\nprovider = \"x\"\n[models.\"k2\"]\n");
216        assert_eq!(m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(), [("k1", false), ("k2", true)]);
217    }
218}
219
220/// Binary names a provider may be installed under, in preference order. The canonical id
221/// (what g4a launches) comes first. Override per provider with `HATCHERY_PROVIDER_BIN_<ID>`
222/// (absolute path or a name on PATH).
223pub fn aliases(provider: &str) -> &'static [&'static str] {
224    match provider {
225        "kimi" => &["kimi", "kimi-cli", "kimi-code"],
226        "claude" => &["claude", "claude-code"],
227        "codex" => &["codex"],
228        "grok" => &["grok"],
229        _ => &[],
230    }
231}
232
233/// A candidate is usable only if `--version` answers and it is not a retired stub
234/// (kimi-cli 1.x prints "no longer maintained" and does nothing).
235fn usable(bin: &Path) -> bool {
236    let Some(out) = run(bin, &["--version"], Duration::from_secs(5)) else { return false };
237    let help = run(bin, &["--help"], Duration::from_secs(5)).unwrap_or_default();
238    !out.trim().is_empty() && !help.contains("no longer maintained") && !out.contains("no longer maintained")
239}
240
241/// Resolve the binary for `provider`: override env, then each alias on PATH, then the
242/// vendor's own install dirs under `extra_homes`.
243pub fn resolve_binary(provider: &str, extra_homes: &[PathBuf]) -> Option<PathBuf> {
244    let key = format!("HATCHERY_PROVIDER_BIN_{}", provider.to_ascii_uppercase());
245    if let Some(v) = std::env::var_os(&key).filter(|v| !v.is_empty()) {
246        let p = PathBuf::from(&v);
247        let p = if p.is_absolute() { Some(p) } else { which(&p.to_string_lossy()) };
248        return p.filter(|p| usable(p));
249    }
250    for name in aliases(provider) {
251        if let Some(p) = which(name).filter(|p| usable(p)) {
252            return Some(p);
253        }
254    }
255    let vendor_dirs: &[&str] = match provider {
256        "kimi" => &[".kimi-code/bin/kimi"],
257        "claude" => &[".claude/local/claude", ".local/bin/claude"],
258        _ => &[],
259    };
260    extra_homes
261        .iter()
262        .flat_map(|h| vendor_dirs.iter().map(move |d| h.join(d)))
263        .find(|p| p.is_file() && usable(p))
264}
265
266/// Make every resolvable provider launchable under its canonical name: a node-private shim
267/// dir with `<id> -> <resolved binary>` symlinks, prepended to PATH (children inherit it).
268#[cfg(unix)]
269pub fn install_shims(shim_dir: &Path, extra_homes: &[PathBuf]) -> Vec<(String, PathBuf)> {
270    let mut done = Vec::new();
271    if std::fs::create_dir_all(shim_dir).is_err() {
272        return done;
273    }
274    for p in PROVIDERS {
275        let link = shim_dir.join(p);
276        let _ = std::fs::remove_file(&link);
277        // Already launchable under its own name and usable: nothing to do.
278        if which(p).filter(|b| usable(b)).is_some() {
279            continue;
280        }
281        if let Some(bin) = resolve_binary(p, extra_homes) {
282            if std::os::unix::fs::symlink(&bin, &link).is_ok() {
283                done.push((p.to_string(), bin));
284            }
285        }
286    }
287    let old = std::env::var_os("PATH").unwrap_or_default();
288    let mut parts = vec![shim_dir.to_path_buf()];
289    parts.extend(std::env::split_paths(&old).filter(|d| d != shim_dir));
290    if let Ok(joined) = std::env::join_paths(parts) {
291        std::env::set_var("PATH", joined);
292    }
293    done
294}
295
296#[cfg(all(test, unix))]
297mod alias_tests {
298    use super::*;
299    use std::os::unix::fs::PermissionsExt;
300
301    fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
302        let p = dir.join(name);
303        std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
304        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
305        p
306    }
307
308    #[test]
309    fn retired_stub_is_skipped_and_vendor_install_is_found() {
310        let d = std::env::temp_dir().join(format!("alias-{}", std::process::id()));
311        let _ = std::fs::remove_dir_all(&d);
312        std::fs::create_dir_all(d.join("home/.kimi-code/bin")).unwrap();
313        let stub = script(&d, "kimi-cli", "echo 'kimi-cli is no longer maintained.'");
314        assert!(!usable(&stub));
315        let real = script(&d.join("home/.kimi-code/bin"), "kimi", "echo 2.1.1");
316        assert!(usable(&real));
317        // PATH may already carry a real kimi on dev boxes; either way the stub is never chosen.
318        let got = resolve_binary("kimi", &[d.join("home")]).expect("kimi resolved");
319        assert_ne!(got, stub);
320    }
321}