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 [`ShimError::Failed`](crate::ShimError::Failed) if the file
147    /// cannot be opened, or [`ShimError::Nul`](crate::ShimError::Nul) for an
148    /// interior NUL in `path`. Both are reachable as
149    /// [`RuntimeError`](super::RuntimeError).
150    pub fn set_file(path: Option<&str>) -> Result<()> {
151        let c_path = path.map(CString::new).transpose()?;
152        let ptr = c_path.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
153        let status = unsafe { sys::common_shim_log_set_file(ptr) };
154        check_status(status)
155    }
156
157    /// Pause the logger's worker thread. Records emitted while paused are
158    /// dropped, which is how upstream keeps progress bars readable.
159    pub fn pause() {
160        unsafe { sys::common_shim_log_pause() }
161    }
162
163    /// Resume after [`pause`].
164    pub fn resume() {
165        unsafe { sys::common_shim_log_resume() }
166    }
167}
168
169// ─────────────────────────────────────────────────────────────────────────────
170// Model resolution
171// ─────────────────────────────────────────────────────────────────────────────
172
173/// Resolving models from Hugging Face and Docker, through llama.cpp's own
174/// cache.
175///
176/// This crate's examples use the `hf-hub` crate, which keeps its own cache.
177/// These functions share the cache llama.cpp's CLI tools use, so a model pulled
178/// by `llama-cli` is found here and vice versa.
179pub mod download {
180    use super::{check_status, read_string, CString, Result, RuntimeError};
181    use llama_cpp_sys_4 as sys;
182
183    /// Resolve `repo[:tag]` to a local path, downloading if needed.
184    ///
185    /// Pass `file` to select one file from a repo with several; leave it `None`
186    /// to let llama.cpp pick.
187    ///
188    /// **This blocks and may download gigabytes.** There is no progress
189    /// callback on this entry point — llama.cpp prints progress through its own
190    /// logger, so [`log`](super::log) controls what you see.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`RuntimeError::Failed`] if resolution or download fails, or
195    /// [`RuntimeError::Nul`] for an interior NUL.
196    pub fn resolve_hf(repo_with_tag: &str, file: Option<&str>) -> Result<String> {
197        let c_repo = CString::new(repo_with_tag)?;
198        let c_file = file.map(CString::new).transpose()?;
199        let file_ptr = c_file.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
200        read_string(|buf, len, expected| unsafe {
201            sys::common_shim_download_resolve_path(c_repo.as_ptr(), file_ptr, buf, len, expected)
202        })
203    }
204
205    /// Split `repo:tag` into its parts.
206    ///
207    /// A bare `user/model` yields an **empty** tag — llama.cpp substitutes no
208    /// default here, it just reports what was written. The repo must be exactly
209    /// `user/model`; anything else is rejected.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`RuntimeError::Failed`] if llama.cpp throws, or
214    /// [`RuntimeError::Nul`] for an interior NUL.
215    pub fn split_repo_tag(repo_with_tag: &str) -> Result<(String, String)> {
216        let c_repo = CString::new(repo_with_tag)?;
217
218        // Both outputs share one call, so size them together and fill together.
219        let mut repo_len: usize = 0;
220        let mut tag_len: usize = 0;
221        let status = unsafe {
222            sys::common_shim_download_split_repo_tag(
223                c_repo.as_ptr(),
224                std::ptr::null_mut(),
225                0,
226                &raw mut repo_len,
227                std::ptr::null_mut(),
228                0,
229                &raw mut tag_len,
230            )
231        };
232        if status != sys::LLAMA_SHIM_BUFFER_TOO_SMALL {
233            check_status(status)?;
234        }
235
236        let mut repo_buf = vec![0u8; repo_len.max(1)];
237        let mut tag_buf = vec![0u8; tag_len.max(1)];
238        let status = unsafe {
239            sys::common_shim_download_split_repo_tag(
240                c_repo.as_ptr(),
241                repo_buf.as_mut_ptr().cast::<std::ffi::c_char>(),
242                repo_buf.len(),
243                &raw mut repo_len,
244                tag_buf.as_mut_ptr().cast::<std::ffi::c_char>(),
245                tag_buf.len(),
246                &raw mut tag_len,
247            )
248        };
249        check_status(status)?;
250        Ok((trim_nul(repo_buf)?, trim_nul(tag_buf)?))
251    }
252
253    fn trim_nul(mut buf: Vec<u8>) -> Result<String> {
254        let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
255        buf.truncate(end);
256        String::from_utf8(buf).map_err(RuntimeError::from)
257    }
258
259    /// Delete a cached model. Returns whether anything was removed.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`RuntimeError::Failed`] if llama.cpp throws, or
264    /// [`RuntimeError::Nul`] for an interior NUL.
265    pub fn remove_cached(repo_with_tag: &str) -> Result<bool> {
266        let c_repo = CString::new(repo_with_tag)?;
267        let rc = unsafe { sys::common_shim_download_remove(c_repo.as_ptr()) };
268        if rc < 0 {
269            check_status(rc)?;
270        }
271        Ok(rc == 1)
272    }
273
274    /// Every model in llama.cpp's cache, as a JSON array of
275    /// `{"repo","tag","name"}` objects.
276    ///
277    /// Returned as JSON rather than a typed struct so this does not pin a JSON
278    /// dependency on the crate, and so upstream adding a field does not break
279    /// the signature.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`RuntimeError::Failed`] if the cache cannot be read.
284    pub fn list_cached_json() -> Result<String> {
285        read_string(|buf, len, expected| unsafe {
286            sys::common_shim_list_cached_models(buf, len, expected)
287        })
288    }
289
290    /// Resolve a Docker model reference to a local path.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`RuntimeError::Failed`] if resolution fails, or
295    /// [`RuntimeError::Nul`] for an interior NUL.
296    pub fn resolve_docker(reference: &str) -> Result<String> {
297        let c_ref = CString::new(reference)?;
298        read_string(|buf, len, expected| unsafe {
299            sys::common_shim_docker_resolve_model(c_ref.as_ptr(), buf, len, expected)
300        })
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn speculative_type_names_round_trip() {
310        // `none` is the one name every build has.
311        let none = SpeculativeType::from_name("none").expect("parse 'none'");
312        assert_eq!(none.name().unwrap(), "none");
313    }
314
315    #[test]
316    fn speculative_type_rejects_unknown_name() {
317        assert!(SpeculativeType::from_name("not-a-strategy").is_err());
318    }
319
320    #[test]
321    fn speculative_type_rejects_interior_nul() {
322        assert!(matches!(
323            SpeculativeType::from_name("no\0ne"),
324            Err(RuntimeError::Nul(_))
325        ));
326    }
327
328    /// The help string must list something; an empty one would mean the type
329    /// table failed to link.
330    #[test]
331    fn all_speculative_names_is_non_empty() {
332        let all = SpeculativeType::all_names().expect("all names");
333        assert!(!all.is_empty(), "no speculative types listed");
334        assert!(all.contains("none"), "got {all}");
335    }
336
337    /// A path that is not a GGUF must report "no speculative support" rather
338    /// than failing — callers probe untrusted paths with this.
339    #[test]
340    fn speculative_types_from_a_missing_file_is_empty() {
341        let types = speculative_types_from_gguf("/definitely/not/a/model.gguf").unwrap();
342        assert!(types.is_empty(), "got {types:?}");
343    }
344
345    #[test]
346    fn speculative_types_rejects_interior_nul() {
347        assert!(matches!(
348            speculative_types_from_gguf("a\0b"),
349            Err(RuntimeError::Nul(_))
350        ));
351    }
352
353    /// A real GGUF that advertises no speculative head must also come back
354    /// empty, which is the case that distinguishes "unreadable" from
355    /// "readable but unsupported".
356    #[test]
357    fn speculative_types_from_a_plain_model_is_empty() {
358        let Some(path) = std::env::var_os("LLAMA_TEST_MODEL") else {
359            eprintln!("SKIP: no test model available");
360            return;
361        };
362        let types = speculative_types_from_gguf(&path.to_string_lossy()).unwrap();
363        assert!(
364            types.iter().all(|t| t.name().unwrap_or_default() != "draft-eagle3"),
365            "a plain model should not advertise EAGLE-3: {types:?}"
366        );
367    }
368
369    #[test]
370    fn split_repo_tag_separates_the_parts() {
371        let (repo, tag) = download::split_repo_tag("ggml-org/models:Q4_K_M").unwrap();
372        assert_eq!(repo, "ggml-org/models");
373        assert_eq!(tag, "Q4_K_M");
374    }
375
376    /// A bare repo yields an empty tag rather than a substituted default —
377    /// callers that need one must supply it themselves.
378    #[test]
379    fn split_repo_tag_leaves_a_missing_tag_empty() {
380        let (repo, tag) = download::split_repo_tag("ggml-org/models").unwrap();
381        assert_eq!(repo, "ggml-org/models");
382        assert_eq!(tag, "");
383    }
384
385    /// A repo that is not `user/model` makes llama.cpp throw; the shim must
386    /// turn that into an error rather than letting it unwind into Rust.
387    #[test]
388    fn split_repo_tag_rejects_a_malformed_repo() {
389        assert!(download::split_repo_tag("not-a-repo").is_err());
390        assert!(download::split_repo_tag("too/many/parts").is_err());
391    }
392
393    #[test]
394    fn split_repo_tag_rejects_interior_nul() {
395        assert!(matches!(
396            download::split_repo_tag("a\0b"),
397            Err(RuntimeError::Nul(_))
398        ));
399    }
400
401    /// Listing an empty or absent cache must be valid JSON, not an error —
402    /// callers parse it unconditionally.
403    #[test]
404    fn list_cached_models_returns_json() {
405        let json = download::list_cached_json().expect("cache listing");
406        assert!(
407            json.starts_with('['),
408            "expected a JSON array, got {json:.40}"
409        );
410    }
411
412    #[test]
413    fn log_controls_do_not_panic() {
414        // Purely global setters; the contract under test is that they link and
415        // are callable, and that restoring the defaults leaves the logger sane.
416        log::set_timestamps(true);
417        log::set_prefix(true);
418        log::set_colors(false);
419        log::set_timestamps(false);
420        log::set_prefix(false);
421        assert!(log::set_file(None).is_ok());
422    }
423
424    #[test]
425    fn log_set_file_rejects_interior_nul() {
426        assert!(matches!(
427            log::set_file(Some("a\0b")),
428            Err(RuntimeError::Nul(_))
429        ));
430    }
431}