car-inference 0.49.0

Local model inference for CAR — Candle backend with Qwen3 models
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Create and *repair* the `uv`-managed Python virtualenvs CAR keeps under
//! `CAR_HOME` — `speech-runtime` (mlx-audio STT/TTS) and `visual-runtime`
//! (mlx-vlm, mlx-lm, vllm-mlx).
//!
//! These venvs are not a convenience: they are the only path by which CAR runs
//! a model architecture its in-process Rust MLX backend does not implement.
//! The `mlx-rs` crate is ops/nn only, and its workspace's `mlx-lm` model zoo is
//! v0.0.1 with two architectures — while `mlx-sys` still pins the MLX core at
//! v0.25.1 (May 2025) against an upstream on 0.32.1. So every new open-weight
//! family (`qwen3_5`, `qwen3_5_moe`, GLM, …) reaches users through one of these
//! Python runtimes or not at all. When a venv rots, CAR stops being able to
//! follow the ecosystem.
//!
//! Two failure modes made that rot permanent, and this module exists to close
//! both:
//!
//! 1. **`uv venv` is not idempotent.** Both provisioners
//!    (`vllm_runtime::provision`, `bootstrap_speech_runtime`) ran it
//!    unconditionally against a directory that usually already existed. `uv`
//!    hard-fails there — `error: A virtual environment already exists at: …`,
//!    non-zero exit — so provisioning aborted before reaching `uv pip install`.
//!    The comment claiming it "is idempotent and leaves existing packages in
//!    place" was simply wrong. A venv that existed could never gain a package,
//!    and a venv that was broken could never be repaired.
//!
//! 2. **A venv's interpreter is an absolute symlink that outlives its target.**
//!    `uv venv --python python3` records `bin/python -> /opt/homebrew/opt/
//!    python@3.13/bin/python3.13`. When Homebrew rotates that formula away
//!    (3.13 → 3.12/3.14), the symlink dangles and *every* console script in the
//!    venv dies at its shebang — mlx_audio, mlx_vlm, mlx_lm, vllm-mlx, all of
//!    it — while the directory still looks populated.
//!
//! Together those produce a silent, unrecoverable outage: the health check
//! correctly reports "not ready", the bootstrap fires, and the bootstrap fails
//! on its first command, forever.
//!
//! [`ensure_venv`] resolves this by branching on what is actually on disk:
//! absent → create; present and healthy → reuse (packages preserved, the
//! caller's `uv pip install` is the idempotent step); present and broken →
//! recreate with `--clear`. Only the recreate path loses installed packages,
//! and the caller reinstalls immediately after, so it is self-healing rather
//! than merely non-fatal.

use std::path::{Path, PathBuf};

use tokio::process::Command;

/// What [`ensure_venv`] had to do to leave a usable venv at the requested root.
///
/// Callers use this to decide whether a package reinstall is merely a no-op
/// refresh (`Reused`) or is restoring a wiped environment (`Recreated`), which
/// is worth logging at a higher level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VenvOutcome {
    /// No venv existed; a fresh one was created.
    Created,
    /// A venv with a working interpreter was already present and left alone.
    /// Installed packages are intact.
    Reused,
    /// A venv was present but its interpreter was unusable, so it was rebuilt
    /// with `--clear`. **Installed packages were discarded** — the caller must
    /// reinstall what it needs.
    Recreated,
}

/// Failure while creating or repairing a managed venv.
#[derive(Debug, thiserror::Error)]
pub enum VenvError {
    #[error(
        "`uv` is required to manage CAR's Python runtimes but was not found on PATH; \
         install uv (https://docs.astral.sh/uv/)"
    )]
    UvMissing,
    #[error("could not create {root}: {detail}")]
    Mkdir { root: PathBuf, detail: String },
    #[error("`uv venv` failed for {root}: {detail}")]
    Create { root: PathBuf, detail: String },
}

/// Executable directory of a venv rooted at `root`: `bin/` on Unix,
/// `Scripts/` on Windows.
///
/// This is what both `uv venv` and `python -m venv` produce, so anything that
/// probes a provisioned venv has to follow it. It used to be `bin/` on every
/// platform, on the stated theory that these Python stacks are
/// Apple-Silicon-only. The **visual** runtime is; the **speech** runtime is the
/// opposite — it is the local speech path for machines *without* Apple's MLX
/// backends, i.e. Windows and Linux. So on Windows CAR probed a directory a
/// successful provision never creates: `speech doctor` reported `Installed: no`
/// against a runtime that was sitting right there, `uv pip install --python
/// <root>/bin/python` pointed at nothing, and `ensure_speech_runtime` rebuilt
/// and re-failed forever.
pub fn venv_bin_dir(root: &Path) -> PathBuf {
    root.join(if cfg!(windows) { "Scripts" } else { "bin" })
}

/// Path to a console script inside a venv rooted at `root`.
///
/// Windows installs entry points as `<name>.exe`; Unix installs them bare.
pub fn venv_program(root: &Path, stem: &str) -> PathBuf {
    let bin_dir = venv_bin_dir(root);
    if cfg!(windows) {
        bin_dir.join(format!("{stem}.exe"))
    } else {
        bin_dir.join(stem)
    }
}

/// Path to the venv's Python interpreter.
pub fn interpreter(root: &Path) -> PathBuf {
    venv_program(root, "python")
}

/// Whether `root` holds a venv whose interpreter actually runs.
///
/// Deliberately executes the interpreter rather than testing for the symlink.
/// `Path::exists` follows symlinks and so does report a dangling `bin/python`
/// as absent, but it cannot catch an interpreter that resolves and is then
/// unusable (a partially removed Homebrew formula, a quarantined binary, an
/// arch mismatch after a machine migration). Running `-c ""` is the only check
/// that answers the question the callers are really asking.
pub fn interpreter_healthy(root: &Path) -> bool {
    let python = interpreter(root);
    if !python.exists() {
        return false;
    }
    std::process::Command::new(&python)
        .args(["-c", ""])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Ensure a usable venv exists at `root`, repairing a broken one in place.
///
/// `python_spec` is passed through to `uv venv --python` and may be an
/// interpreter name (`python3.12`), an absolute path, or a bare version
/// (`3.12`) that `uv` will download and manage itself.
///
/// Idempotent, which is the property the callers wrongly assumed of `uv venv`
/// itself: calling this against a healthy venv is a cheap no-op that preserves
/// installed packages.
pub async fn ensure_venv(root: &Path, python_spec: &str) -> Result<VenvOutcome, VenvError> {
    if which("uv").is_none() {
        return Err(VenvError::UvMissing);
    }

    // An existing, working venv is left exactly as it is. `uv pip install` --
    // which every caller runs next -- is the idempotent step that reconciles
    // package state, so there is nothing for us to do here.
    if root.exists() {
        if interpreter_healthy(root) {
            return Ok(VenvOutcome::Reused);
        }
        // Present but unusable. `--clear` is the only way past `uv`'s
        // already-exists error, and it discards site-packages; the caller's
        // reinstall is what makes this a repair rather than a demolition.
        create(root, python_spec, true).await?;
        return Ok(VenvOutcome::Recreated);
    }

    std::fs::create_dir_all(root).map_err(|e| VenvError::Mkdir {
        root: root.to_path_buf(),
        detail: e.to_string(),
    })?;
    // `create_dir_all` just made `root` exist, so `uv venv` would now hit the
    // same already-exists error a bare directory triggers. Clear past it.
    create(root, python_spec, true).await?;
    Ok(VenvOutcome::Created)
}

/// Run `uv venv [--clear] --python <spec> <root>`.
async fn create(root: &Path, python_spec: &str, clear: bool) -> Result<(), VenvError> {
    let mut args: Vec<String> = vec!["venv".into()];
    if clear {
        args.push("--clear".into());
    }
    args.push("--python".into());
    args.push(python_spec.to_string());
    args.push(root.display().to_string());

    let output = Command::new("uv")
        .args(&args)
        .kill_on_drop(true)
        .output()
        .await
        .map_err(|e| VenvError::Create {
            root: root.to_path_buf(),
            detail: e.to_string(),
        })?;

    if output.status.success() {
        Ok(())
    } else {
        Err(VenvError::Create {
            root: root.to_path_buf(),
            detail: format!(
                "uv exited with {}: {}",
                output.status,
                String::from_utf8_lossy(&output.stderr).trim()
            ),
        })
    }
}

/// Minimal `which`: first hit for `name` across PATH.
///
/// Uses [`std::env::split_paths`] rather than `split(':')`, and consults
/// `PATHEXT` rather than trying only the bare name. Both matter on Windows,
/// where PATH is `;`-separated and an executable needs an extension: the old
/// version split `C:\a;C:\b` into `C` / `\a;C` / `\b` and then looked for an
/// extensionless `uv`, so it could not find *any* program there. Every caller
/// therefore saw [`VenvError::UvMissing`] on Windows even with `uv` installed.
fn which(name: &str) -> Option<PathBuf> {
    which_in(&std::env::var_os("PATH")?, name)
}

/// `which` against an explicit PATH value, so tests can exercise the
/// separator/extension handling without mutating the process environment out
/// from under every other test in the binary.
fn which_in(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
    // Windows resolves a bare command name against PATHEXT; `.EXE` covers uv
    // and the interpreters, `.BAT`/`.CMD` cover shim-style installs.
    let extensions: Vec<String> = if cfg!(windows) {
        std::env::var("PATHEXT")
            .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
            .split(';')
            .filter(|ext| !ext.is_empty())
            .map(str::to_string)
            .collect()
    } else {
        Vec::new()
    };

    std::env::split_paths(path).find_map(|dir| {
        let bare = dir.join(name);
        if bare.is_file() {
            return Some(bare);
        }
        extensions.iter().find_map(|ext| {
            let candidate = dir.join(format!("{name}{ext}"));
            candidate.is_file().then_some(candidate)
        })
    })
}

/// Lay down a venv at `root` that [`interpreter_healthy`] accepts, offline and
/// without `uv`.
///
/// Test support, and exported because `car-cli`'s CLI tests need exactly this
/// fixture too. A hand-rolled copy in each test file — hardcoding `bin/python`
/// while the probe read `Scripts\python.exe` — is precisely the drift that hid
/// the Windows layout bug, so there is one definition and everyone calls it.
///
/// Unix gets a `#!/bin/sh` stub with the exec bit set: a runnable
/// "interpreter" that needs no Python on the machine. Windows has no
/// executable format a test can author from Rust, so it shells out to the host
/// interpreter's own `venv` module — which is also the most faithful fixture
/// available, since a Windows venv's `Scripts\python.exe` really is a copy of
/// the base interpreter. `--without-pip` keeps it fast and network-free.
///
/// Panics rather than degrading: a fixture that quietly produced a *not*-ready
/// runtime would turn every test built on it into a green no-op.
#[doc(hidden)]
pub fn seed_ready_venv(root: &Path) {
    #[cfg(windows)]
    {
        let python = ["python.exe", "python3.exe", "py.exe"]
            .into_iter()
            .find_map(which)
            .unwrap_or_else(|| {
                panic!(
                    "seed_ready_venv needs a host Python on PATH: on Windows the \
                     venv's Scripts\\python.exe must be a real interpreter, and \
                     none of python.exe / python3.exe / py.exe was found"
                )
            });
        let output = std::process::Command::new(&python)
            .args(["-m", "venv", "--without-pip"])
            .arg(root)
            .output()
            .unwrap_or_else(|err| panic!("spawning {} -m venv: {err}", python.display()));
        assert!(
            output.status.success(),
            "{} -m venv failed with {}: {}",
            python.display(),
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    #[cfg(unix)]
    {
        let python = interpreter(root);
        std::fs::create_dir_all(python.parent().expect("interpreter has a parent")).unwrap();
        std::fs::write(&python, b"#!/bin/sh\nexit 0\n").unwrap();
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
    }

    assert!(
        interpreter_healthy(root),
        "fixture failed to produce a runnable interpreter at {}",
        interpreter(root).display()
    );
}

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

    fn uv_available() -> bool {
        which("uv").is_some()
    }

    /// The Windows leg of CI failed on this: `bin/python` everywhere meant the
    /// speech runtime — the *non*-Apple-Silicon local speech path — was probed
    /// at a path no successful provision creates. Lock the layout per platform
    /// so it cannot silently revert.
    #[test]
    fn interpreter_follows_the_platform_venv_layout() {
        let p = interpreter(Path::new("/tmp/rt"));
        if cfg!(windows) {
            assert!(p.ends_with("Scripts/python.exe"), "got {}", p.display());
        } else {
            assert!(p.ends_with("bin/python"), "got {}", p.display());
        }
    }

    /// Console scripts are `.exe`-suffixed on Windows and bare on Unix — the
    /// other half of the same layout, and what `SpeechRuntime` looks for.
    #[test]
    fn console_scripts_follow_the_platform_venv_layout() {
        let p = venv_program(Path::new("/tmp/rt"), "mlx_audio.stt.generate");
        if cfg!(windows) {
            assert!(
                p.ends_with("Scripts/mlx_audio.stt.generate.exe"),
                "got {}",
                p.display()
            );
        } else {
            assert!(
                p.ends_with("bin/mlx_audio.stt.generate"),
                "got {}",
                p.display()
            );
        }
    }

    /// `which` used to `split(':')`, which on Windows shreds `C:\a;C:\b` into
    /// nonsense and finds nothing — so `ensure_venv` reported `uv` missing
    /// there even when it was installed. Prove it resolves a program the test
    /// puts on a PATH-shaped string, extension and all.
    #[test]
    fn which_resolves_across_the_platform_path_separator() {
        let tmp = std::env::temp_dir().join(format!("car-venv-which-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        let name = "car-which-probe";
        let file = if cfg!(windows) {
            tmp.join(format!("{name}.exe"))
        } else {
            tmp.join(name)
        };
        std::fs::write(&file, b"").unwrap();

        // Two entries, so the separator is actually exercised.
        let joined =
            std::env::join_paths([PathBuf::from("/nonexistent/car-which"), tmp.clone()]).unwrap();
        let found = which_in(&joined, name);
        std::fs::remove_dir_all(&tmp).ok();

        let found = found.expect("which_in should resolve the probe on PATH");
        assert_eq!(found.parent(), file.parent());
        // Compared case-insensitively on purpose. `PATHEXT` is conventionally
        // uppercase, so on Windows `which_in` returns `…\car-which-probe.EXE`
        // while the file was written as `.exe`; the filesystem is
        // case-insensitive but `Path` equality is byte-exact, so `assert_eq!`
        // on the whole path would fail there for a resolution that is correct.
        assert!(
            found
                .file_name()
                .zip(file.file_name())
                .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)),
            "resolved {}, expected {} (case-insensitive)",
            found.display(),
            file.display()
        );
    }

    #[test]
    fn missing_root_is_not_healthy() {
        assert!(!interpreter_healthy(Path::new(
            "/nonexistent/car-managed-venv"
        )));
    }

    // Unix-only: the dangling state can only be built with a POSIX symlink.
    #[cfg(unix)]
    #[test]
    fn dangling_interpreter_is_not_healthy() {
        let tmp = std::env::temp_dir().join(format!("car-venv-dangle-{}", std::process::id()));
        let bin = tmp.join("bin");
        std::fs::create_dir_all(&bin).unwrap();
        // Exactly the shape a Homebrew formula rotation leaves behind.
        std::os::unix::fs::symlink(
            "/opt/homebrew/opt/python@0.0/bin/python0.0",
            bin.join("python"),
        )
        .unwrap();
        assert!(!interpreter_healthy(&tmp));
        let _ = std::fs::remove_dir_all(&tmp);
    }

    /// The regression this module exists for: a second `ensure_venv` against an
    /// existing venv must succeed. `uv venv` alone fails here.
    #[tokio::test]
    async fn existing_healthy_venv_is_reused_not_recreated() {
        if !uv_available() {
            return;
        }
        let tmp = std::env::temp_dir().join(format!("car-venv-reuse-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&tmp);

        let first = ensure_venv(&tmp, "python3").await.expect("first create");
        assert_eq!(first, VenvOutcome::Created);
        assert!(interpreter_healthy(&tmp));

        let second = ensure_venv(&tmp, "python3").await.expect("second call");
        assert_eq!(second, VenvOutcome::Reused);
        assert!(interpreter_healthy(&tmp));

        let _ = std::fs::remove_dir_all(&tmp);
    }

    /// A venv whose interpreter has been rotated away is repaired in place.
    // Unix-only: rotating the interpreter away needs a POSIX symlink.
    #[cfg(unix)]
    #[tokio::test]
    async fn broken_venv_is_recreated() {
        if !uv_available() {
            return;
        }
        let tmp = std::env::temp_dir().join(format!("car-venv-repair-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&tmp);

        ensure_venv(&tmp, "python3").await.expect("create");
        // Simulate `brew` dropping the formula the venv was built against.
        let python = interpreter(&tmp);
        std::fs::remove_file(&python).unwrap();
        std::os::unix::fs::symlink("/opt/homebrew/opt/python@0.0/bin/python0.0", &python).unwrap();
        assert!(!interpreter_healthy(&tmp));

        let outcome = ensure_venv(&tmp, "python3").await.expect("repair");
        assert_eq!(outcome, VenvOutcome::Recreated);
        assert!(interpreter_healthy(&tmp));

        let _ = std::fs::remove_dir_all(&tmp);
    }
}