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}