Skip to main content

llama_cpp_4/
runtime.rs

1//! Speculative-type introspection, llama.cpp's logger, and model resolution.
2//!
3//! Three small surfaces that share one thing: they are things you do *around*
4//! inference rather than during it.
5
6use std::ffi::CString;
7
8use llama_cpp_sys_4 as sys;
9
10use crate::shim::{check_status, read_i32s, read_string, ShimError};
11
12/// Errors from this module.
13pub type RuntimeError = ShimError;
14
15type Result<T> = std::result::Result<T, RuntimeError>;
16
17// ─────────────────────────────────────────────────────────────────────────────
18// Speculative-type introspection
19// ─────────────────────────────────────────────────────────────────────────────
20
21/// A speculative-decoding strategy, as llama.cpp names it.
22///
23/// Values match `common_speculative_type`. Rather than pin discriminants that
24/// upstream reorders, this keeps the raw value and converts through llama.cpp's
25/// own name table.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct SpeculativeType(pub i32);
28
29impl SpeculativeType {
30    /// llama.cpp's name for this type, e.g. `"draft-eagle3"`.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`RuntimeError::Failed`] if llama.cpp cannot name it.
35    pub fn name(self) -> Result<String> {
36        // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
37        // and the shim reports how much it needed; nothing is read back unless it
38        // reported success.
39        read_string(|buf, len, expected| unsafe {
40            sys::common_shim_speculative_type_to_str(self.0, buf, len, expected)
41        })
42    }
43
44    /// Parse a name from llama.cpp's own table.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`RuntimeError::InvalidArg`] if the name is not recognised —
49    /// upstream reports that with a sentinel value rather than an error, which
50    /// the shim translates so an unknown name cannot be stored and silently
51    /// select nothing. [`RuntimeError::Nul`] for an interior NUL.
52    pub fn from_name(name: &str) -> Result<Self> {
53        let c_name = CString::new(name)?;
54        let mut raw = 0i32;
55        let status =
56            // SAFETY: the `CString` arguments are alive for the duration of the call, and any
57            // out-param is a stack local the shim writes through.
58            unsafe { sys::common_shim_speculative_type_from_name(c_name.as_ptr(), &raw mut raw) };
59        check_status(status)?;
60        Ok(Self(raw))
61    }
62
63    /// Every type name llama.cpp recognises, as one string — what its CLI
64    /// prints in help text.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`RuntimeError::Failed`] if llama.cpp throws.
69    pub fn all_names() -> Result<String> {
70        // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
71        // and the shim reports how much it needed; nothing is read back unless it
72        // reported success.
73        read_string(|buf, len, expected| unsafe {
74            sys::common_shim_speculative_all_types_str(buf, len, expected)
75        })
76    }
77}
78
79/// Which speculative strategies a draft GGUF supports, read from its metadata.
80///
81/// The point is that this does **not** load the model: pointing at a 4 GB draft
82/// checkpoint to discover it has no EAGLE-3 head costs a metadata read, not a
83/// full load and a failed session construction. Use it to pick between
84/// [`Eagle3Session`](crate::eagle::Eagle3Session),
85/// [`MtpSession`](crate::mtp::MtpSession) and the rest before committing.
86///
87/// Wraps `common_speculative_types_from_gguf`. Returns an empty vector for a
88/// file that is not a readable GGUF, or one advertising no speculative support.
89///
90/// # Errors
91///
92/// Returns [`RuntimeError::Nul`] for an interior NUL in `path`.
93pub fn speculative_types_from_gguf(path: &str) -> Result<Vec<SpeculativeType>> {
94    let c_path = CString::new(path)?;
95    // SAFETY: the `CString` arguments are alive for the duration of the call, and any
96    // out-param is a stack local the shim writes through.
97    let raw = read_i32s(|out, cap, len| unsafe {
98        sys::common_shim_speculative_types_from_gguf(c_path.as_ptr(), out, cap, len)
99    })?;
100    Ok(raw.into_iter().map(SpeculativeType).collect())
101}
102
103// ─────────────────────────────────────────────────────────────────────────────
104// Logging
105// ─────────────────────────────────────────────────────────────────────────────
106
107/// Controls for llama.cpp's own logger — the output the C++ library produces,
108/// which is separate from anything this crate emits through `tracing`.
109///
110/// Every one of these is documented upstream as **not thread-safe**; call them
111/// during setup, before inference starts.
112///
113/// For redirecting llama.cpp's output into a Rust logger instead, use
114/// [`log_set`](crate::log_set), which installs a callback.
115pub mod log {
116    use super::{check_status, CString, Result};
117    use llama_cpp_sys_4 as sys;
118
119    /// Drop log records below this verbosity.
120    pub fn set_verbosity(verbosity: i32) {
121        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
122        // the library owns whatever it returns.
123        unsafe { sys::common_shim_log_set_verbosity(verbosity) }
124    }
125
126    /// The verbosity threshold configured for a `ggml_log_level`.
127    #[must_use]
128    pub fn verbosity_for_level(level: i32) -> i32 {
129        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
130        // the library owns whatever it returns.
131        unsafe { sys::common_shim_log_get_verbosity(level) }
132    }
133
134    /// Include timestamps in the log prefix.
135    pub fn set_timestamps(timestamps: bool) {
136        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
137        // the library owns whatever it returns.
138        unsafe { sys::common_shim_log_set_timestamps(timestamps) }
139    }
140
141    /// Include the level prefix on each record.
142    pub fn set_prefix(prefix: bool) {
143        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
144        // the library owns whatever it returns.
145        unsafe { sys::common_shim_log_set_prefix(prefix) }
146    }
147
148    /// Colourise output. Disabling is what you want when the destination is not
149    /// a terminal.
150    pub fn set_colors(colors: bool) {
151        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
152        // the library owns whatever it returns.
153        unsafe { sys::common_shim_log_set_colors(colors) }
154    }
155
156    /// Emit one JSON object per record instead of human-readable text — for
157    /// shipping llama.cpp's own logs into a structured pipeline.
158    pub fn set_jsonl(jsonl: bool) {
159        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
160        // the library owns whatever it returns.
161        unsafe { sys::common_shim_log_set_jsonl(jsonl) }
162    }
163
164    /// Write to `path`, or pass `None` to stop writing to a file.
165    ///
166    /// # Errors
167    ///
168    /// Returns [`ShimError::Failed`](crate::ShimError::Failed) if the file
169    /// cannot be opened, or [`ShimError::Nul`](crate::ShimError::Nul) for an
170    /// interior NUL in `path`. Both are reachable as
171    /// [`RuntimeError`](super::RuntimeError).
172    pub fn set_file(path: Option<&str>) -> Result<()> {
173        let c_path = path.map(CString::new).transpose()?;
174        let ptr = c_path.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
175        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
176        // the library owns whatever it returns.
177        let status = unsafe { sys::common_shim_log_set_file(ptr) };
178        check_status(status)
179    }
180
181    /// Pause the logger's worker thread. Records emitted while paused are
182    /// dropped, which is how upstream keeps progress bars readable.
183    pub fn pause() {
184        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
185        // the library owns whatever it returns.
186        unsafe { sys::common_shim_log_pause() }
187    }
188
189    /// Resume after [`pause`].
190    pub fn resume() {
191        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
192        // the library owns whatever it returns.
193        unsafe { sys::common_shim_log_resume() }
194    }
195}
196
197// ─────────────────────────────────────────────────────────────────────────────
198// Model resolution
199// ─────────────────────────────────────────────────────────────────────────────
200
201/// Resolving models from Hugging Face and Docker, through llama.cpp's own
202/// cache.
203///
204/// This crate's examples use the `hf-hub` crate, which keeps its own cache.
205/// These functions share the cache llama.cpp's CLI tools use, so a model pulled
206/// by `llama-cli` is found here and vice versa.
207pub mod download {
208    use super::{check_status, read_string, CString, Result, RuntimeError};
209    use llama_cpp_sys_4 as sys;
210
211    /// Resolve `repo[:tag]` to a local path, downloading if needed.
212    ///
213    /// Pass `file` to select one file from a repo with several; leave it `None`
214    /// to let llama.cpp pick.
215    ///
216    /// **This blocks and may download gigabytes.** There is no progress
217    /// callback on this entry point — llama.cpp prints progress through its own
218    /// logger, so [`log`](super::log) controls what you see.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`RuntimeError::Failed`] if resolution or download fails, or
223    /// [`RuntimeError::Nul`] for an interior NUL.
224    pub fn resolve_hf(repo_with_tag: &str, file: Option<&str>) -> Result<String> {
225        let c_repo = CString::new(repo_with_tag)?;
226        let c_file = file.map(CString::new).transpose()?;
227        let file_ptr = c_file.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
228        // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
229        // and the shim reports how much it needed; nothing is read back unless it
230        // reported success.
231        read_string(|buf, len, expected| unsafe {
232            sys::common_shim_download_resolve_path(c_repo.as_ptr(), file_ptr, buf, len, expected)
233        })
234    }
235
236    /// Split `repo:tag` into its parts.
237    ///
238    /// A bare `user/model` yields an **empty** tag — llama.cpp substitutes no
239    /// default here, it just reports what was written. The repo must be exactly
240    /// `user/model`; anything else is rejected.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`RuntimeError::Failed`] if llama.cpp throws, or
245    /// [`RuntimeError::Nul`] for an interior NUL.
246    pub fn split_repo_tag(repo_with_tag: &str) -> Result<(String, String)> {
247        let c_repo = CString::new(repo_with_tag)?;
248
249        // Both outputs share one call, so size them together and fill together.
250        let mut repo_len: usize = 0;
251        let mut tag_len: usize = 0;
252        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
253        // the library owns whatever it returns.
254        let status = unsafe {
255            sys::common_shim_download_split_repo_tag(
256                c_repo.as_ptr(),
257                std::ptr::null_mut(),
258                0,
259                &raw mut repo_len,
260                std::ptr::null_mut(),
261                0,
262                &raw mut tag_len,
263            )
264        };
265        if status != sys::LLAMA_SHIM_BUFFER_TOO_SMALL {
266            check_status(status)?;
267        }
268
269        let mut repo_buf = vec![0u8; repo_len.max(1)];
270        let mut tag_buf = vec![0u8; tag_len.max(1)];
271        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
272        // the library owns whatever it returns.
273        let status = unsafe {
274            sys::common_shim_download_split_repo_tag(
275                c_repo.as_ptr(),
276                repo_buf.as_mut_ptr().cast::<std::ffi::c_char>(),
277                repo_buf.len(),
278                &raw mut repo_len,
279                tag_buf.as_mut_ptr().cast::<std::ffi::c_char>(),
280                tag_buf.len(),
281                &raw mut tag_len,
282            )
283        };
284        check_status(status)?;
285        Ok((trim_nul(repo_buf)?, trim_nul(tag_buf)?))
286    }
287
288    fn trim_nul(mut buf: Vec<u8>) -> Result<String> {
289        let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
290        buf.truncate(end);
291        String::from_utf8(buf).map_err(RuntimeError::from)
292    }
293
294    /// Delete a cached model. Returns whether anything was removed.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`RuntimeError::Failed`] if llama.cpp throws, or
299    /// [`RuntimeError::Nul`] for an interior NUL.
300    pub fn remove_cached(repo_with_tag: &str) -> Result<bool> {
301        let c_repo = CString::new(repo_with_tag)?;
302        // SAFETY: the arguments are either scalars or pointers that outlive the call, and
303        // the library owns whatever it returns.
304        let rc = unsafe { sys::common_shim_download_remove(c_repo.as_ptr()) };
305        if rc < 0 {
306            check_status(rc)?;
307        }
308        Ok(rc == 1)
309    }
310
311    /// Every model in llama.cpp's cache, as a JSON array of
312    /// `{"repo","tag","name"}` objects.
313    ///
314    /// Returned as JSON rather than a typed struct so this does not pin a JSON
315    /// dependency on the crate, and so upstream adding a field does not break
316    /// the signature.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`RuntimeError::Failed`] if the cache cannot be read.
321    pub fn list_cached_json() -> Result<String> {
322        // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
323        // and the shim reports how much it needed; nothing is read back unless it
324        // reported success.
325        read_string(|buf, len, expected| unsafe {
326            sys::common_shim_list_cached_models(buf, len, expected)
327        })
328    }
329
330    /// Resolve a Docker model reference to a local path.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`RuntimeError::Failed`] if resolution fails, or
335    /// [`RuntimeError::Nul`] for an interior NUL.
336    pub fn resolve_docker(reference: &str) -> Result<String> {
337        let c_ref = CString::new(reference)?;
338        // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
339        // and the shim reports how much it needed; nothing is read back unless it
340        // reported success.
341        read_string(|buf, len, expected| unsafe {
342            sys::common_shim_docker_resolve_model(c_ref.as_ptr(), buf, len, expected)
343        })
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn speculative_type_names_round_trip() {
353        // `none` is the one name every build has.
354        let none = SpeculativeType::from_name("none").expect("parse 'none'");
355        assert_eq!(none.name().unwrap(), "none");
356    }
357
358    #[test]
359    fn speculative_type_rejects_unknown_name() {
360        assert!(SpeculativeType::from_name("not-a-strategy").is_err());
361    }
362
363    #[test]
364    fn speculative_type_rejects_interior_nul() {
365        assert!(matches!(
366            SpeculativeType::from_name("no\0ne"),
367            Err(RuntimeError::Nul(_))
368        ));
369    }
370
371    /// The help string must list something; an empty one would mean the type
372    /// table failed to link.
373    #[test]
374    fn all_speculative_names_is_non_empty() {
375        let all = SpeculativeType::all_names().expect("all names");
376        assert!(!all.is_empty(), "no speculative types listed");
377        assert!(all.contains("none"), "got {all}");
378    }
379
380    /// A path that is not a GGUF must report "no speculative support" rather
381    /// than failing — callers probe untrusted paths with this.
382    #[test]
383    fn speculative_types_from_a_missing_file_is_empty() {
384        let types = speculative_types_from_gguf("/definitely/not/a/model.gguf").unwrap();
385        assert!(types.is_empty(), "got {types:?}");
386    }
387
388    #[test]
389    fn speculative_types_rejects_interior_nul() {
390        assert!(matches!(
391            speculative_types_from_gguf("a\0b"),
392            Err(RuntimeError::Nul(_))
393        ));
394    }
395
396    /// A real GGUF that advertises no speculative head must also come back
397    /// empty, which is the case that distinguishes "unreadable" from
398    /// "readable but unsupported".
399    #[test]
400    fn speculative_types_from_a_plain_model_is_empty() {
401        let Some(path) = std::env::var_os("LLAMA_TEST_MODEL") else {
402            eprintln!("SKIP: no test model available");
403            return;
404        };
405        let types = speculative_types_from_gguf(&path.to_string_lossy()).unwrap();
406        assert!(
407            types
408                .iter()
409                .all(|t| t.name().unwrap_or_default() != "draft-eagle3"),
410            "a plain model should not advertise EAGLE-3: {types:?}"
411        );
412    }
413
414    #[test]
415    fn split_repo_tag_separates_the_parts() {
416        let (repo, tag) = download::split_repo_tag("ggml-org/models:Q4_K_M").unwrap();
417        assert_eq!(repo, "ggml-org/models");
418        assert_eq!(tag, "Q4_K_M");
419    }
420
421    /// A bare repo yields an empty tag rather than a substituted default —
422    /// callers that need one must supply it themselves.
423    #[test]
424    fn split_repo_tag_leaves_a_missing_tag_empty() {
425        let (repo, tag) = download::split_repo_tag("ggml-org/models").unwrap();
426        assert_eq!(repo, "ggml-org/models");
427        assert_eq!(tag, "");
428    }
429
430    /// A repo that is not `user/model` makes llama.cpp throw; the shim must
431    /// turn that into an error rather than letting it unwind into Rust.
432    #[test]
433    fn split_repo_tag_rejects_a_malformed_repo() {
434        assert!(download::split_repo_tag("not-a-repo").is_err());
435        assert!(download::split_repo_tag("too/many/parts").is_err());
436    }
437
438    #[test]
439    fn split_repo_tag_rejects_interior_nul() {
440        assert!(matches!(
441            download::split_repo_tag("a\0b"),
442            Err(RuntimeError::Nul(_))
443        ));
444    }
445
446    /// Listing an empty or absent cache must be valid JSON, not an error —
447    /// callers parse it unconditionally.
448    #[test]
449    fn list_cached_models_returns_json() {
450        let json = download::list_cached_json().expect("cache listing");
451        assert!(
452            json.starts_with('['),
453            "expected a JSON array, got {json:.40}"
454        );
455    }
456
457    #[test]
458    fn log_controls_do_not_panic() {
459        // Purely global setters; the contract under test is that they link and
460        // are callable, and that restoring the defaults leaves the logger sane.
461        log::set_timestamps(true);
462        log::set_prefix(true);
463        log::set_colors(false);
464        log::set_timestamps(false);
465        log::set_prefix(false);
466        assert!(log::set_file(None).is_ok());
467    }
468
469    #[test]
470    fn log_set_file_rejects_interior_nul() {
471        assert!(matches!(
472            log::set_file(Some("a\0b")),
473            Err(RuntimeError::Nul(_))
474        ));
475    }
476}