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}