ik_llama_cpp_2/model.rs
1//! Safe wrapper around `llama_model` ([`LlamaModel`]).
2
3pub mod chat;
4pub mod lora;
5pub mod meta;
6pub mod params;
7
8use std::ffi::CString;
9use std::num::NonZeroU16;
10use std::os::raw::c_char;
11use std::path::Path;
12use std::ptr::NonNull;
13
14use ik_llama_cpp_sys as sys;
15
16use crate::llama_backend::LlamaBackend;
17use crate::token::LlamaToken;
18use crate::LlamaError;
19
20pub use params::LlamaModelParams;
21
22/// A loaded ik_llama.cpp model.
23///
24/// Uses ik's model-pointer tokenizer/vocab API (`llama_tokenize(model, …)`,
25/// `llama_token_bos(model)`, …), which differs from modern stock llama.cpp's
26/// vocab-pointer API.
27#[derive(Debug)]
28pub struct LlamaModel {
29 pub(crate) model: NonNull<sys::llama_model>,
30}
31
32// SAFETY: after loading, the model is immutable and safe to share across threads
33// (matches llama-cpp-2's contract).
34unsafe impl Send for LlamaModel {}
35unsafe impl Sync for LlamaModel {}
36
37/// Whether to prepend a BOS (beginning-of-sequence) token when tokenizing.
38///
39/// Mirrors `llama-cpp-2`'s `model::AddBos`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum AddBos {
42 /// Prepend the BOS token.
43 Always,
44 /// Do not prepend the BOS token.
45 Never,
46}
47
48impl LlamaModel {
49 /// Load a model from a single GGUF file (for split models, pass the merged
50 /// file or the first shard — see the crate docs on SPECIAL_SPLIT).
51 ///
52 /// # Invariant
53 ///
54 /// The [`LlamaBackend`] passed here (and any derived [`crate::LlamaContext`])
55 /// **must outlive** the returned model. Dropping the backend first runs
56 /// `llama_backend_free` before the model/context are freed, which is unsound.
57 /// The natural nested/reverse-drop order (backend → model → context declared
58 /// outer-to-inner, dropped inner-to-outer) satisfies this. (This mirrors the
59 /// `llama-cpp-2` anchor's contract.)
60 pub fn load_from_file(
61 _backend: &LlamaBackend,
62 path: impl AsRef<Path>,
63 params: &LlamaModelParams,
64 ) -> Result<Self, LlamaError> {
65 let path = path.as_ref();
66 let c_path = CString::new(path.to_str().ok_or(LlamaError::InvalidPath)?)?;
67 // SAFETY: valid C string + a fully-initialized params struct.
68 let raw = unsafe { sys::llama_model_load_from_file(c_path.as_ptr(), params.params) };
69 NonNull::new(raw)
70 .map(|model| Self { model })
71 .ok_or_else(|| LlamaError::ModelLoad(path.display().to_string()))
72 }
73
74 /// Number of vocabulary tokens.
75 #[must_use]
76 pub fn n_vocab(&self) -> i32 {
77 unsafe { sys::llama_n_vocab(self.model.as_ptr()) }
78 }
79
80 /// Number of MTP / NextN prediction layers (0 if the model has none).
81 #[must_use]
82 pub fn n_nextn_layer(&self) -> i32 {
83 unsafe { sys::llama_model_n_nextn_layer(self.model.as_ptr()) }
84 }
85
86 /// Beginning-of-sequence token.
87 #[must_use]
88 pub fn token_bos(&self) -> LlamaToken {
89 LlamaToken(unsafe { sys::llama_token_bos(self.model.as_ptr()) })
90 }
91
92 /// End-of-sequence token.
93 #[must_use]
94 pub fn token_eos(&self) -> LlamaToken {
95 LlamaToken(unsafe { sys::llama_token_eos(self.model.as_ptr()) })
96 }
97
98 /// Whether `token` marks end-of-generation (EOS/EOT/etc.).
99 #[must_use]
100 pub fn is_eog(&self, token: LlamaToken) -> bool {
101 unsafe { sys::llama_token_is_eog(self.model.as_ptr(), token.0) }
102 }
103
104 /// Whether `token` marks end-of-generation (alias of [`Self::is_eog`],
105 /// matching the `llama-cpp-2` method name).
106 #[must_use]
107 pub fn is_eog_token(&self, token: LlamaToken) -> bool {
108 self.is_eog(token)
109 }
110
111 /// Create a context for this model (matches `llama-cpp-2`'s
112 /// `model.new_context(&backend, params)`).
113 ///
114 /// The [`LlamaBackend`] argument is accepted for API parity; the returned
115 /// context borrows `self`, so the model must outlive it.
116 ///
117 /// # Errors
118 ///
119 /// [`LlamaError::ContextCreation`] if `llama_init_from_model` returns null.
120 pub fn new_context<'a>(
121 &'a self,
122 _backend: &LlamaBackend,
123 params: crate::context::params::LlamaContextParams,
124 ) -> Result<crate::context::LlamaContext<'a>, LlamaError> {
125 crate::context::LlamaContext::new(self, ¶ms)
126 }
127
128 /// Tokenize `text`, choosing whether to prepend BOS via [`AddBos`] (matches
129 /// `llama-cpp-2`'s `str_to_token`). Special tokens are parsed.
130 ///
131 /// # Errors
132 ///
133 /// [`LlamaError::Nul`] on an interior NUL byte; [`LlamaError::Tokenize`] if
134 /// tokenization fails.
135 pub fn str_to_token(&self, text: &str, add_bos: AddBos) -> Result<Vec<LlamaToken>, LlamaError> {
136 self.tokenize(text, matches!(add_bos, AddBos::Always))
137 }
138
139 /// Tokenize `text`. `add_bos` prepends the BOS token; special tokens are parsed.
140 pub fn tokenize(&self, text: &str, add_bos: bool) -> Result<Vec<LlamaToken>, LlamaError> {
141 let c_text = CString::new(text)?;
142 let text_len = text.len() as i32;
143 // Generous first guess; on overflow ik returns -(required).
144 let mut cap = (text.len() + 16) as i32;
145 let mut buf = vec![0 as sys::llama_token; cap as usize];
146 // SAFETY: buf has `cap` slots.
147 let mut n = unsafe {
148 sys::llama_tokenize(
149 self.model.as_ptr(),
150 c_text.as_ptr(),
151 text_len,
152 buf.as_mut_ptr(),
153 cap,
154 add_bos,
155 true,
156 )
157 };
158 if n < 0 {
159 cap = -n;
160 buf = vec![0 as sys::llama_token; cap as usize];
161 n = unsafe {
162 sys::llama_tokenize(
163 self.model.as_ptr(),
164 c_text.as_ptr(),
165 text_len,
166 buf.as_mut_ptr(),
167 cap,
168 add_bos,
169 true,
170 )
171 };
172 }
173 if n < 0 {
174 return Err(LlamaError::Tokenize);
175 }
176 buf.truncate(n as usize);
177 Ok(buf.into_iter().map(LlamaToken).collect())
178 }
179
180 /// Raw bytes of a single token's piece.
181 ///
182 /// `special` controls whether special/control tokens render as text;
183 /// `lstrip` strips that many leading spaces. `buf_size` is the initial
184 /// buffer guess — on overflow the call retries once with the exact size.
185 ///
186 /// # Errors
187 ///
188 /// [`LlamaError::TokenOutOfRange`] if `token` is not in `[0, n_vocab)`;
189 /// [`LlamaError::TokenToPiece`] if the C conversion fails.
190 pub fn token_to_piece_bytes(
191 &self,
192 token: LlamaToken,
193 buf_size: usize,
194 special: bool,
195 lstrip: Option<NonZeroU16>,
196 ) -> Result<Vec<u8>, LlamaError> {
197 // ik's tokenizer does an out-of-range `cache.at(token)` (throws
198 // std::out_of_range) for a token outside `[0, n_vocab)`; that C++ throw
199 // would unwind across the `extern "C"` boundary and abort the process.
200 // Reject a bad id here so it is a normal error from safe code, not a crash.
201 let n_vocab = self.n_vocab();
202 if token.0 < 0 || token.0 >= n_vocab {
203 return Err(LlamaError::TokenOutOfRange {
204 token: token.0,
205 n_vocab,
206 });
207 }
208 let lstrip_i = lstrip.map_or(0, |n| i32::from(n.get()));
209 let mut buf = vec![0u8; buf_size.max(1)];
210 let write = |buf: &mut [u8]| unsafe {
211 sys::llama_token_to_piece(
212 self.model.as_ptr(),
213 token.0,
214 buf.as_mut_ptr().cast::<c_char>(),
215 i32::try_from(buf.len()).unwrap_or(i32::MAX),
216 lstrip_i,
217 special,
218 )
219 };
220 let mut n = write(&mut buf);
221 if n < 0 {
222 buf = vec![0u8; (-n) as usize];
223 n = write(&mut buf);
224 if n < 0 {
225 return Err(LlamaError::TokenToPiece);
226 }
227 }
228 buf.truncate(n as usize);
229 Ok(buf)
230 }
231
232 /// Convert a single token to text, decoding its bytes incrementally through
233 /// `decoder` (so a multi-byte UTF-8 sequence split across token boundaries is
234 /// reassembled). Matches `llama-cpp-2`'s `token_to_piece`.
235 ///
236 /// `special` renders special/control tokens as text; `lstrip` strips leading
237 /// spaces. Use one decoder (`encoding_rs::UTF_8.new_decoder()`) per stream.
238 ///
239 /// A multi-byte sequence split at the very end of the stream stays buffered
240 /// in `decoder` (this never flushes with `last = true`); that matches the
241 /// `llama-cpp-2` anchor and is a non-issue for a stream ending on a token
242 /// boundary.
243 ///
244 /// # Errors
245 ///
246 /// [`LlamaError::TokenToPiece`] if the C conversion fails.
247 pub fn token_to_piece(
248 &self,
249 token: LlamaToken,
250 decoder: &mut encoding_rs::Decoder,
251 special: bool,
252 lstrip: Option<NonZeroU16>,
253 ) -> Result<String, LlamaError> {
254 let bytes = self.token_to_piece_bytes(token, 8, special, lstrip)?;
255 let mut out = String::with_capacity(bytes.len() + 16);
256 let _ = decoder.decode_to_string(&bytes, &mut out, false);
257 Ok(out)
258 }
259
260 /// Convert a single token to its text piece, lossily (UTF-8 replacement on
261 /// invalid bytes). Convenience over [`Self::token_to_piece`] for callers not
262 /// doing incremental streaming.
263 ///
264 /// # Errors
265 ///
266 /// [`LlamaError::TokenToPiece`] if the C conversion fails.
267 pub fn token_to_piece_lossy(&self, token: LlamaToken) -> Result<String, LlamaError> {
268 let bytes = self.token_to_piece_bytes(token, 8, false, None)?;
269 Ok(String::from_utf8_lossy(&bytes).into_owned())
270 }
271
272 /// Detokenize a slice of tokens into a string, decoding incrementally through
273 /// a single UTF-8 decoder (`special = false`).
274 ///
275 /// Note: this concatenates per-token pieces, so special tokens are dropped and
276 /// spacing may differ slightly from a true detokenizer. ik only exposes the
277 /// vocab-pointer `llama_detokenize` (the model-pointer overload is commented
278 /// out in the header); a full detokenizer path is a follow-up.
279 pub fn detokenize(&self, tokens: &[LlamaToken]) -> Result<String, LlamaError> {
280 let mut decoder = encoding_rs::UTF_8.new_decoder();
281 let mut s = String::new();
282 for &t in tokens {
283 s.push_str(&self.token_to_piece(t, &mut decoder, false, None)?);
284 }
285 Ok(s)
286 }
287}
288
289impl Drop for LlamaModel {
290 fn drop(&mut self) {
291 unsafe { sys::llama_free_model(self.model.as_ptr()) };
292 }
293}