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