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}