Skip to main content

llama_crab/high_level/
completion.rs

1//! Text completion driver.
2
3use serde_json::Value;
4
5use crate::error::{LlamaError, Result};
6use crate::logit_bias::LlamaLogitBias;
7use crate::sampling::LlamaSampler;
8use crate::token::LlamaToken;
9use llama_crab_sys as sys;
10
11use super::Llama;
12
13/// The result of a text completion.
14#[derive(Debug, Clone, PartialEq)]
15pub struct Completion {
16    /// Concatenated generated text.
17    pub text: String,
18    /// Number of tokens generated.
19    pub n_tokens: usize,
20    /// Reason generation stopped (`"length"`, `"eos"`, or `"stop"`).
21    pub stop_reason: StopReason,
22    /// Optional per-token logprob information.
23    pub logprobs: Option<CompletionLogprobs>,
24}
25
26/// Per-token logprob data for a completion.
27#[derive(Debug, Clone, PartialEq)]
28pub struct CompletionLogprobs {
29    /// Token text fragments.
30    pub tokens: Vec<String>,
31    /// Byte offsets into the logical completion text.
32    pub text_offset: Vec<usize>,
33    /// Logprob of each selected token. Prompt tokens can be represented as `None`.
34    pub token_logprobs: Vec<Option<f32>>,
35    /// Top candidate logprobs for each selected token.
36    pub top_logprobs: Vec<Option<Vec<TokenLogprob>>>,
37}
38
39impl CompletionLogprobs {
40    fn new() -> Self {
41        Self {
42            tokens: Vec::new(),
43            text_offset: Vec::new(),
44            token_logprobs: Vec::new(),
45            top_logprobs: Vec::new(),
46        }
47    }
48
49    fn from_record(record: TokenLogprobRecord) -> Self {
50        let mut logprobs = Self::new();
51        logprobs.push(record);
52        logprobs
53    }
54
55    fn push(&mut self, record: TokenLogprobRecord) {
56        self.tokens.push(record.token);
57        self.text_offset.push(record.text_offset);
58        self.token_logprobs.push(Some(record.token_logprob));
59        self.top_logprobs.push(Some(record.top_logprobs));
60    }
61}
62
63/// One top-logprob candidate.
64#[derive(Debug, Clone, PartialEq)]
65pub struct TokenLogprob {
66    /// Token id.
67    pub token: i32,
68    /// Token text.
69    pub text: String,
70    /// Log probability.
71    pub logprob: f32,
72}
73
74#[derive(Debug, Clone, PartialEq)]
75struct TokenLogprobRecord {
76    token: String,
77    text_offset: usize,
78    token_logprob: f32,
79    top_logprobs: Vec<TokenLogprob>,
80}
81
82/// Options for high-level text completion.
83#[derive(Debug, Clone, PartialEq)]
84pub struct CompletionOptions {
85    /// Maximum number of generated tokens.
86    pub max_tokens: usize,
87    /// Stop strings that terminate generation without being emitted.
88    pub stop_sequences: Vec<String>,
89    /// Sampling pipeline options.
90    pub sampling: SamplingOptions,
91    /// Include the prompt at the beginning of the returned text.
92    pub echo_prompt: bool,
93    /// Text appended to the final completion.
94    pub suffix: Option<String>,
95    /// Additive token-logit biases applied before sampling.
96    pub logit_bias: Vec<LlamaLogitBias>,
97    /// Minimum generated tokens before EOS/EOT can terminate generation.
98    pub min_tokens: usize,
99    /// Number of top logprobs to retain per generated token.
100    pub logprobs: Option<usize>,
101}
102
103/// Sampling options used by high-level generation helpers.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct SamplingOptions {
106    /// Temperature. `0.0` selects greedy decoding; negative values select
107    /// distribution sampling directly.
108    pub temperature: f32,
109    /// Top-K cutoff.
110    pub top_k: i32,
111    /// Top-P nucleus cutoff.
112    pub top_p: f32,
113    /// Tail-free sampling cutoff. `1.0` disables it.
114    pub tfs_z: f32,
115    /// Min-P cutoff.
116    pub min_p: f32,
117    /// Locally-typical cutoff.
118    pub typical_p: f32,
119    /// Minimum candidates retained by probability filters.
120    pub min_keep: usize,
121    /// Repeat penalty lookback window.
122    pub penalty_last_n: i32,
123    /// Repeat penalty multiplier.
124    pub repeat_penalty: f32,
125    /// Frequency penalty.
126    pub frequency_penalty: f32,
127    /// Presence penalty.
128    pub presence_penalty: f32,
129    /// Mirostat mode: `0` disabled, `1` v1, `2` v2.
130    pub mirostat_mode: i32,
131    /// Mirostat target entropy.
132    pub mirostat_tau: f32,
133    /// Mirostat learning rate.
134    pub mirostat_eta: f32,
135    /// Sampler seed. `None` delegates random seeding to llama.cpp.
136    pub seed: Option<u32>,
137}
138
139impl Default for SamplingOptions {
140    fn default() -> Self {
141        Self {
142            temperature: 0.8,
143            top_k: 40,
144            top_p: 0.95,
145            tfs_z: 1.0,
146            min_p: 0.05,
147            typical_p: 1.0,
148            min_keep: 1,
149            penalty_last_n: 64,
150            repeat_penalty: 1.0,
151            frequency_penalty: 0.0,
152            presence_penalty: 0.0,
153            mirostat_mode: 0,
154            mirostat_tau: 5.0,
155            mirostat_eta: 0.1,
156            seed: None,
157        }
158    }
159}
160
161impl SamplingOptions {
162    /// Defaults commonly used for chat generation.
163    #[must_use]
164    pub fn chat() -> Self {
165        Self {
166            temperature: 0.2,
167            ..Self::default()
168        }
169    }
170
171    /// Set the temperature.
172    #[must_use]
173    pub const fn with_temperature(mut self, temperature: f32) -> Self {
174        self.temperature = temperature;
175        self
176    }
177
178    /// Set the random seed.
179    #[must_use]
180    pub const fn with_seed(mut self, seed: u32) -> Self {
181        self.seed = Some(seed);
182        self
183    }
184
185    /// Build a sampler chain from these options for the given model/context.
186    pub fn build_sampler(self, llama: &Llama) -> Result<LlamaSampler> {
187        let mut samplers = Vec::new();
188        let seed = self.seed.unwrap_or(u32::MAX);
189
190        if self.repeat_penalty != 1.0
191            || self.frequency_penalty != 0.0
192            || self.presence_penalty != 0.0
193        {
194            samplers.push(
195                LlamaSampler::penalties(
196                    self.penalty_last_n,
197                    self.repeat_penalty,
198                    self.frequency_penalty,
199                    self.presence_penalty,
200                )
201                .ok_or_else(|| LlamaError::Batch("sampler_init_penalties returned null".into()))?,
202            );
203        }
204
205        if self.temperature < 0.0 {
206            samplers.push(
207                LlamaSampler::dist(seed)
208                    .ok_or_else(|| LlamaError::Batch("sampler_init_dist returned null".into()))?,
209            );
210        } else if self.temperature == 0.0 {
211            samplers
212                .push(LlamaSampler::greedy().ok_or_else(|| {
213                    LlamaError::Batch("sampler_init_greedy returned null".into())
214                })?);
215        } else if self.mirostat_mode == 1 {
216            samplers.push(
217                LlamaSampler::mirostat(
218                    llama.model().n_vocab(),
219                    seed,
220                    self.mirostat_tau,
221                    self.mirostat_eta,
222                    100,
223                )
224                .ok_or_else(|| LlamaError::Batch("sampler_init_mirostat returned null".into()))?,
225            );
226        } else if self.mirostat_mode == 2 {
227            samplers.push(
228                LlamaSampler::mirostat_v2(seed, self.mirostat_tau, self.mirostat_eta).ok_or_else(
229                    || LlamaError::Batch("sampler_init_mirostat_v2 returned null".into()),
230                )?,
231            );
232        } else {
233            samplers.push(
234                LlamaSampler::top_k(self.top_k)
235                    .ok_or_else(|| LlamaError::Batch("sampler_init_top_k returned null".into()))?,
236            );
237            if self.tfs_z != 1.0 {
238                samplers.push(
239                    LlamaSampler::tail_free(self.tfs_z, self.min_keep).ok_or_else(|| {
240                        LlamaError::Batch("sampler_init_tail_free returned null".into())
241                    })?,
242                );
243            }
244            samplers.push(
245                LlamaSampler::typical(self.typical_p, self.min_keep).ok_or_else(|| {
246                    LlamaError::Batch("sampler_init_typical returned null".into())
247                })?,
248            );
249            samplers.push(
250                LlamaSampler::top_p(self.top_p, self.min_keep)
251                    .ok_or_else(|| LlamaError::Batch("sampler_init_top_p returned null".into()))?,
252            );
253            samplers.push(
254                LlamaSampler::min_p(self.min_p, self.min_keep)
255                    .ok_or_else(|| LlamaError::Batch("sampler_init_min_p returned null".into()))?,
256            );
257            samplers.push(
258                LlamaSampler::temp(self.temperature)
259                    .ok_or_else(|| LlamaError::Batch("sampler_init_temp returned null".into()))?,
260            );
261            samplers.push(
262                LlamaSampler::dist(seed)
263                    .ok_or_else(|| LlamaError::Batch("sampler_init_dist returned null".into()))?,
264            );
265        }
266
267        LlamaSampler::chain(samplers, false)
268            .ok_or_else(|| LlamaError::Batch("sampler_chain_init returned null".into()))
269    }
270}
271
272impl CompletionOptions {
273    /// Create completion options with no stop sequences.
274    #[must_use]
275    pub const fn new(max_tokens: usize) -> Self {
276        Self {
277            max_tokens,
278            stop_sequences: Vec::new(),
279            sampling: SamplingOptions {
280                temperature: 0.0,
281                top_k: 40,
282                top_p: 0.95,
283                tfs_z: 1.0,
284                min_p: 0.05,
285                typical_p: 1.0,
286                min_keep: 1,
287                penalty_last_n: 64,
288                repeat_penalty: 1.0,
289                frequency_penalty: 0.0,
290                presence_penalty: 0.0,
291                mirostat_mode: 0,
292                mirostat_tau: 5.0,
293                mirostat_eta: 0.1,
294                seed: None,
295            },
296            echo_prompt: false,
297            suffix: None,
298            logit_bias: Vec::new(),
299            min_tokens: 0,
300            logprobs: None,
301        }
302    }
303
304    /// Create completion options with the default probabilistic sampler.
305    #[must_use]
306    pub fn sampled(max_tokens: usize) -> Self {
307        Self {
308            max_tokens,
309            stop_sequences: Vec::new(),
310            sampling: SamplingOptions::default(),
311            echo_prompt: false,
312            suffix: None,
313            logit_bias: Vec::new(),
314            min_tokens: 0,
315            logprobs: None,
316        }
317    }
318
319    /// Replace sampling options.
320    #[must_use]
321    pub const fn with_sampling(mut self, sampling: SamplingOptions) -> Self {
322        self.sampling = sampling;
323        self
324    }
325
326    /// Include or suppress the prompt in the returned text.
327    #[must_use]
328    pub const fn with_echo_prompt(mut self, echo_prompt: bool) -> Self {
329        self.echo_prompt = echo_prompt;
330        self
331    }
332
333    /// Append a suffix to the final completion text.
334    #[must_use]
335    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
336        let suffix = suffix.into();
337        self.suffix = (!suffix.is_empty()).then_some(suffix);
338        self
339    }
340
341    /// Replace all token-logit biases.
342    #[must_use]
343    pub fn with_logit_biases<I>(mut self, biases: I) -> Self
344    where
345        I: IntoIterator<Item = LlamaLogitBias>,
346    {
347        self.logit_bias = biases.into_iter().collect();
348        self
349    }
350
351    /// Require at least this many generated tokens before EOS/EOT can stop generation.
352    #[must_use]
353    pub const fn with_min_tokens(mut self, min_tokens: usize) -> Self {
354        self.min_tokens = min_tokens;
355        self
356    }
357
358    /// Retain per-token logprobs and this many top candidates.
359    #[must_use]
360    pub const fn with_logprobs(mut self, logprobs: usize) -> Self {
361        self.logprobs = Some(logprobs);
362        self
363    }
364
365    /// Build the sampler chain represented by these options.
366    pub fn build_sampler(&self, llama: &Llama) -> Result<LlamaSampler> {
367        build_completion_sampler(llama, self)
368    }
369
370    /// Add one stop sequence.
371    #[must_use]
372    pub fn with_stop_sequence(mut self, stop_sequence: impl Into<String>) -> Self {
373        let stop_sequence = stop_sequence.into();
374        if !stop_sequence.is_empty() {
375            self.stop_sequences.push(stop_sequence);
376        }
377        self
378    }
379
380    /// Add multiple stop sequences.
381    #[must_use]
382    pub fn with_stop_sequences<I, S>(mut self, stop_sequences: I) -> Self
383    where
384        I: IntoIterator<Item = S>,
385        S: Into<String>,
386    {
387        self.stop_sequences.extend(
388            stop_sequences
389                .into_iter()
390                .map(Into::into)
391                .filter(|s: &String| !s.is_empty()),
392        );
393        self
394    }
395}
396
397/// A synchronous streaming completion callback chunk.
398#[derive(Debug, Clone, PartialEq)]
399pub struct CompletionChunk {
400    /// Text made available by this callback invocation.
401    pub text: String,
402    /// Token that produced this chunk, if the chunk corresponds to a sampled token.
403    ///
404    /// Terminal flush chunks can carry no token when they only report a final
405    /// [`StopReason`].
406    pub token: Option<LlamaToken>,
407    /// Cumulative number of generated non-EOS tokens.
408    pub n_tokens: usize,
409    /// Terminal reason when this is the last chunk.
410    pub stop_reason: Option<StopReason>,
411    /// Optional per-token logprob information for this chunk.
412    pub logprobs: Option<CompletionLogprobs>,
413}
414
415/// Why a completion ended.
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
417pub enum StopReason {
418    /// Reached the requested `max_tokens`.
419    Length,
420    /// Model emitted an end-of-sequence token.
421    Eos,
422    /// Custom stop string matched.
423    Stop,
424    /// Model emitted one or more tool calls (streaming consumers only).
425    ToolCalls,
426}
427
428/// Return value from synchronous streaming callbacks.
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
430pub enum StreamControl {
431    /// Continue generation.
432    Continue,
433    /// Stop generation after the current callback.
434    Stop,
435}
436
437/// Generate a single completion for `prompt`.
438///
439/// This is the simplest possible inference loop: tokenize → decode the prompt
440/// → sample → decode one token at a time. For more control, use the lower
441/// level [`crate::LlamaContext`] / [`crate::sampling::LlamaSampler`] APIs.
442///
443/// The KV cache for sequence 0 is cleared before each call, so each call is
444/// independent. For multi-turn conversations, build the full history into the
445/// prompt and call again.
446pub fn create_completion(llama: &mut Llama, prompt: &str, max_tokens: usize) -> Result<Completion> {
447    create_completion_with_options(llama, prompt, CompletionOptions::new(max_tokens))
448}
449
450/// Generate a single completion for `prompt` using high-level options.
451pub fn create_completion_with_options(
452    llama: &mut Llama,
453    prompt: &str,
454    options: CompletionOptions,
455) -> Result<Completion> {
456    create_completion_stream(llama, prompt, options, |_| StreamControl::Continue)
457}
458
459/// Generate a single completion using a caller-provided sampler.
460pub fn create_completion_with_sampler(
461    llama: &mut Llama,
462    prompt: &str,
463    options: CompletionOptions,
464    sampler: &mut LlamaSampler,
465) -> Result<Completion> {
466    create_completion_stream_with_sampler(llama, prompt, options, sampler, |_| {
467        StreamControl::Continue
468    })
469}
470
471/// Generate a completion and synchronously call `on_chunk` as text becomes
472/// available.
473pub fn create_completion_stream<F>(
474    llama: &mut Llama,
475    prompt: &str,
476    options: CompletionOptions,
477    on_chunk: F,
478) -> Result<Completion>
479where
480    F: FnMut(CompletionChunk) -> StreamControl,
481{
482    let mut sampler = options.build_sampler(llama)?;
483    create_completion_stream_with_sampler(llama, prompt, options, &mut sampler, on_chunk)
484}
485
486fn build_completion_sampler(llama: &Llama, options: &CompletionOptions) -> Result<LlamaSampler> {
487    let base_sampler = options.sampling.build_sampler(llama)?;
488    if options.logit_bias.is_empty() {
489        return Ok(base_sampler);
490    }
491
492    let raw_biases: Vec<sys::llama_logit_bias> = options
493        .logit_bias
494        .iter()
495        .map(|bias| sys::llama_logit_bias {
496            token: bias.token,
497            bias: bias.bias,
498        })
499        .collect();
500    let bias_sampler = unsafe { LlamaSampler::logit_bias(llama.model().n_vocab(), &raw_biases) }
501        .ok_or_else(|| LlamaError::Batch("sampler_init_logit_bias returned null".into()))?;
502    LlamaSampler::chain(vec![bias_sampler, base_sampler], false)
503        .ok_or_else(|| LlamaError::Batch("sampler_chain_init returned null".into()))
504}
505
506/// Generate a streaming completion using a caller-provided sampler.
507pub fn create_completion_stream_with_sampler<F>(
508    llama: &mut Llama,
509    prompt: &str,
510    options: CompletionOptions,
511    sampler: &mut LlamaSampler,
512    mut on_chunk: F,
513) -> Result<Completion>
514where
515    F: FnMut(CompletionChunk) -> StreamControl,
516{
517    // Clear sequence 0 so the new batch can start at position 0 regardless
518    // of any previous decode. p0 = p1 = -1 means "the entire range".
519    let _ = llama.context().seq_rm(0, -1, -1);
520
521    let tokens = llama.model().tokenize(prompt, true, true)?;
522
523    // Build a batch with the prompt; only the last token produces logits.
524    let mut batch = crate::batch::LlamaBatch::new(tokens.len(), 1);
525    for (i, &t) in tokens.iter().enumerate() {
526        batch
527            .add(t, i as i32, &[0], i + 1 == tokens.len())
528            .map_err(LlamaError::from)?;
529    }
530    llama.context().decode(&batch)?;
531
532    let eos = llama.model().token_eos();
533    let eot = llama.model().token_eot();
534    let mut generated = String::new();
535    let mut stop_buffer = StopBuffer::new(options.stop_sequences);
536    let mut last_pos = tokens.len() as i32;
537    let mut n_generated = 0_usize;
538    let mut stop_reason = StopReason::Length;
539
540    if options.echo_prompt
541        && emit_chunk(
542            &mut on_chunk,
543            &mut generated,
544            prompt.to_string(),
545            None,
546            0,
547            None,
548            None,
549        ) == StreamControl::Stop
550    {
551        return Ok(Completion {
552            text: generated,
553            n_tokens: 0,
554            stop_reason: StopReason::Stop,
555            logprobs: None,
556        });
557    }
558
559    let mut logprobs = options.logprobs.map(|_| CompletionLogprobs::new());
560    for _ in 0..options.max_tokens {
561        // `idx` is the index in the *current* batch whose logits we sample
562        // from. For the initial prompt (all tokens in one batch) the logits
563        // are at the last position. For every subsequent single-token batch
564        // the logits are at index 0.
565        let idx = if n_generated == 0 {
566            (tokens.len() as i32) - 1
567        } else {
568            0
569        };
570        let suppress_eog = n_generated < options.min_tokens;
571        let next = sample_next_token(llama, sampler, idx, suppress_eog)?;
572        let logits_for_logprobs = options
573            .logprobs
574            .map(|_| logits_for_logprobs(llama, suppress_eog))
575            .transpose()?;
576        if next == eos || next == eot {
577            stop_reason = StopReason::Eos;
578            break;
579        }
580        let piece = llama.model().detokenize(&[next], false)?;
581        let mut chunk_logprobs = None;
582        if let (Some(logprobs), Some(top_n), Some(logits)) =
583            (&mut logprobs, options.logprobs, logits_for_logprobs)
584        {
585            let text_offset = if options.echo_prompt {
586                generated.len()
587            } else {
588                prompt.len() + generated.len()
589            };
590            let mut record = token_logprob_record(&logits, next, piece.clone(), text_offset, top_n);
591            for candidate in &mut record.top_logprobs {
592                if candidate.text == candidate.token.to_string() {
593                    candidate.text = llama
594                        .model()
595                        .detokenize(&[LlamaToken::from(candidate.token)], false)?;
596                }
597            }
598            chunk_logprobs = Some(CompletionLogprobs::from_record(record.clone()));
599            logprobs.push(record);
600        }
601        n_generated += 1;
602        let step = stop_buffer.push(&piece);
603        if step.stopped {
604            stop_reason = StopReason::Stop;
605            if emit_chunk(
606                &mut on_chunk,
607                &mut generated,
608                step.text,
609                Some(next),
610                n_generated,
611                None,
612                chunk_logprobs,
613            ) == StreamControl::Stop
614            {
615                stop_reason = StopReason::Stop;
616            }
617            break;
618        }
619        if emit_chunk(
620            &mut on_chunk,
621            &mut generated,
622            step.text,
623            Some(next),
624            n_generated,
625            None,
626            chunk_logprobs,
627        ) == StreamControl::Stop
628        {
629            stop_reason = StopReason::Stop;
630            break;
631        }
632        // Feed back the new token.
633        let mut single = crate::batch::LlamaBatch::new(1, 1);
634        single
635            .add(next, last_pos, &[0], true)
636            .map_err(LlamaError::from)?;
637        llama.context().decode(&single)?;
638        last_pos += 1;
639    }
640
641    let final_text = format!(
642        "{}{}",
643        stop_buffer.flush(),
644        options.suffix.as_deref().unwrap_or("")
645    );
646    if emit_chunk(
647        &mut on_chunk,
648        &mut generated,
649        final_text,
650        None,
651        n_generated,
652        Some(stop_reason),
653        None,
654    ) == StreamControl::Stop
655    {
656        stop_reason = StopReason::Stop;
657    }
658
659    Ok(Completion {
660        text: generated,
661        n_tokens: n_generated,
662        stop_reason,
663        logprobs,
664    })
665}
666
667fn logits_for_logprobs(llama: &mut Llama, suppress_eog: bool) -> Result<Vec<f32>> {
668    let ctx = llama.context().raw_handle();
669    let logits = unsafe { sys::llama_get_logits(ctx) };
670    if logits.is_null() {
671        return Err(LlamaError::Batch("no logits".into()));
672    }
673    let n_vocab = llama.model().n_vocab() as usize;
674    let mut logits = unsafe { std::slice::from_raw_parts(logits, n_vocab) }.to_vec();
675    if suppress_eog {
676        for token in [llama.model().token_eos(), llama.model().token_eot()] {
677            let raw = token.raw();
678            if raw >= 0 && (raw as usize) < logits.len() {
679                logits[raw as usize] = f32::NEG_INFINITY;
680            }
681        }
682    }
683    Ok(logits)
684}
685
686fn token_logprob_record(
687    logits: &[f32],
688    selected: LlamaToken,
689    selected_text: String,
690    text_offset: usize,
691    top_n: usize,
692) -> TokenLogprobRecord {
693    let logprobs = logits_to_logprobs(logits);
694    let selected_id = selected.raw();
695    let selected_logprob = selected_logprob(&logprobs, selected_id);
696    let mut candidates: Vec<(i32, f32)> = logprobs
697        .iter()
698        .enumerate()
699        .map(|(token, &logprob)| (token as i32, logprob))
700        .collect();
701    candidates.sort_by(|(_, lhs), (_, rhs)| rhs.total_cmp(lhs));
702    candidates.truncate(top_n);
703    if !candidates.iter().any(|(token, _)| *token == selected_id) {
704        candidates.push((selected_id, selected_logprob));
705    }
706
707    let top_logprobs = candidates
708        .into_iter()
709        .map(|(token, logprob)| TokenLogprob {
710            token,
711            text: if token == selected_id {
712                selected_text.clone()
713            } else {
714                token.to_string()
715            },
716            logprob,
717        })
718        .collect();
719
720    TokenLogprobRecord {
721        token: selected_text,
722        text_offset,
723        token_logprob: selected_logprob,
724        top_logprobs,
725    }
726}
727
728fn selected_logprob(logprobs: &[f32], selected: i32) -> f32 {
729    if selected < 0 {
730        return f32::NEG_INFINITY;
731    }
732    logprobs
733        .get(selected as usize)
734        .copied()
735        .unwrap_or(f32::NEG_INFINITY)
736}
737
738fn logits_to_logprobs(logits: &[f32]) -> Vec<f32> {
739    let max = logits
740        .iter()
741        .copied()
742        .filter(|value| value.is_finite())
743        .fold(f32::NEG_INFINITY, f32::max);
744    if !max.is_finite() {
745        return vec![f32::NEG_INFINITY; logits.len()];
746    }
747    let sum_exp: f32 = logits
748        .iter()
749        .copied()
750        .filter(|value| value.is_finite())
751        .map(|value| (value - max).exp())
752        .sum();
753    let log_sum_exp = max + sum_exp.ln();
754    logits
755        .iter()
756        .map(|&value| {
757            if value.is_finite() {
758                value - log_sum_exp
759            } else {
760                f32::NEG_INFINITY
761            }
762        })
763        .collect()
764}
765
766fn sample_next_token(
767    llama: &mut Llama,
768    sampler: &mut LlamaSampler,
769    idx: i32,
770    suppress_eog: bool,
771) -> Result<LlamaToken> {
772    if !suppress_eog {
773        return Ok(unsafe { sampler.sample(llama.context().raw_handle(), idx) });
774    }
775
776    let eos = llama.model().token_eos();
777    let eot = llama.model().token_eot();
778    let ctx = llama.context().raw_handle();
779    let logits = unsafe { sys::llama_get_logits_ith(ctx, idx) };
780    if logits.is_null() {
781        return Err(LlamaError::Batch(format!("no logits at index {idx}")));
782    }
783
784    let mut restore = Vec::new();
785    for token in [eos, eot] {
786        let raw = token.raw();
787        if raw >= 0 && raw < llama.model().n_vocab() {
788            let slot = unsafe { logits.add(raw as usize) };
789            let previous = unsafe { *slot };
790            unsafe {
791                *slot = f32::NEG_INFINITY;
792            }
793            restore.push((slot, previous));
794        }
795    }
796
797    let sampled = unsafe { sampler.sample(ctx, idx) };
798    for (slot, previous) in restore {
799        unsafe {
800            *slot = previous;
801        }
802    }
803    Ok(sampled)
804}
805
806#[cfg(test)]
807fn format_completion_text(prompt: &str, generated: &str, options: &CompletionOptions) -> String {
808    let mut text = String::new();
809    if options.echo_prompt {
810        text.push_str(prompt);
811    }
812    text.push_str(generated);
813    if let Some(suffix) = &options.suffix {
814        text.push_str(suffix);
815    }
816    text
817}
818
819/// Create a GBNF grammar from a JSON Schema (used with `LlamaSampler::grammar`).
820///
821/// # Example
822///
823/// ```no_run
824/// use llama_crab::high_level::completion::json_schema_grammar;
825/// use serde_json::json;
826/// let grammar = json_schema_grammar(&json!({
827///     "type": "object",
828///     "properties": {
829///         "answer": { "type": "string" }
830///     },
831///     "required": ["answer"]
832/// })).unwrap();
833/// assert!(grammar.contains("root"));
834/// ```
835pub fn json_schema_grammar(schema: &Value) -> Result<String> {
836    crate::json_schema::schema_to_grammar(schema, "root")
837        .map_err(|e| LlamaError::JsonSchemaToGrammar(e.to_string()))
838}
839
840fn emit_chunk<F>(
841    on_chunk: &mut F,
842    generated: &mut String,
843    text: String,
844    token: Option<LlamaToken>,
845    n_tokens: usize,
846    stop_reason: Option<StopReason>,
847    logprobs: Option<CompletionLogprobs>,
848) -> StreamControl
849where
850    F: FnMut(CompletionChunk) -> StreamControl,
851{
852    if text.is_empty() && stop_reason.is_none() {
853        return StreamControl::Continue;
854    }
855
856    generated.push_str(&text);
857    on_chunk(CompletionChunk {
858        text,
859        token,
860        n_tokens,
861        stop_reason,
862        logprobs,
863    })
864}
865
866#[derive(Debug, Clone, PartialEq, Eq)]
867struct StopBuffer {
868    pending: String,
869    stop_sequences: Vec<String>,
870    stopped: bool,
871}
872
873#[derive(Debug, Clone, PartialEq, Eq)]
874struct StopBufferStep {
875    text: String,
876    stopped: bool,
877}
878
879impl StopBuffer {
880    fn new(stop_sequences: Vec<String>) -> Self {
881        Self {
882            pending: String::new(),
883            stop_sequences: stop_sequences
884                .into_iter()
885                .filter(|s| !s.is_empty())
886                .collect(),
887            stopped: false,
888        }
889    }
890
891    fn push(&mut self, text: &str) -> StopBufferStep {
892        if self.stopped {
893            return StopBufferStep {
894                text: String::new(),
895                stopped: true,
896            };
897        }
898        if self.stop_sequences.is_empty() {
899            return StopBufferStep {
900                text: text.to_string(),
901                stopped: false,
902            };
903        }
904
905        self.pending.push_str(text);
906        if let Some(stop_start) = self.find_stop_start() {
907            self.stopped = true;
908            let text = self.pending[..stop_start].to_string();
909            self.pending.clear();
910            return StopBufferStep {
911                text,
912                stopped: true,
913            };
914        }
915
916        let hold_start = self.longest_stop_prefix_suffix_start();
917        let text = self.pending[..hold_start].to_string();
918        self.pending = self.pending[hold_start..].to_string();
919        StopBufferStep {
920            text,
921            stopped: false,
922        }
923    }
924
925    fn flush(&mut self) -> String {
926        std::mem::take(&mut self.pending)
927    }
928
929    fn find_stop_start(&self) -> Option<usize> {
930        self.stop_sequences
931            .iter()
932            .filter_map(|stop| self.pending.find(stop))
933            .min()
934    }
935
936    fn longest_stop_prefix_suffix_start(&self) -> usize {
937        let mut hold_start = self.pending.len();
938        for (start, _) in self.pending.char_indices() {
939            let suffix = &self.pending[start..];
940            if self
941                .stop_sequences
942                .iter()
943                .any(|stop| stop.starts_with(suffix))
944            {
945                hold_start = start;
946                break;
947            }
948        }
949        hold_start
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::{format_completion_text, token_logprob_record, CompletionOptions, StopBuffer};
956    use crate::LlamaToken;
957
958    #[test]
959    fn stop_buffer_holds_stop_prefix_across_token_boundaries() {
960        let mut buffer = StopBuffer::new(vec!["</stop>".to_string()]);
961
962        let first = buffer.push("hello </");
963        assert_eq!(first.text, "hello ");
964        assert!(!first.stopped);
965
966        let second = buffer.push("stop> ignored");
967        assert_eq!(second.text, "");
968        assert!(second.stopped);
969    }
970
971    #[test]
972    fn stop_buffer_removes_stop_sequence_inside_chunk() {
973        let mut buffer = StopBuffer::new(vec!["END".to_string()]);
974
975        let step = buffer.push("answerEND trailing");
976
977        assert_eq!(step.text, "answer");
978        assert!(step.stopped);
979    }
980
981    #[test]
982    fn completion_options_apply_echo_and_suffix_to_final_text() {
983        let options = CompletionOptions::new(16)
984            .with_echo_prompt(true)
985            .with_suffix(" done");
986
987        let text = format_completion_text("prompt: ", "answer", &options);
988
989        assert_eq!(text, "prompt: answer done");
990    }
991
992    #[test]
993    fn completion_options_apply_min_tokens() {
994        let options = CompletionOptions::new(8).with_min_tokens(3);
995
996        assert_eq!(options.min_tokens, 3);
997    }
998
999    #[test]
1000    fn completion_options_apply_logprobs() {
1001        let options = CompletionOptions::new(8).with_logprobs(3);
1002
1003        assert_eq!(options.logprobs, Some(3));
1004    }
1005
1006    #[test]
1007    fn token_logprobs_include_selected_token_and_top_candidates() {
1008        let record = token_logprob_record(
1009            &[0.0, 2.0, 1.0],
1010            LlamaToken::from(0),
1011            "zero".to_string(),
1012            4,
1013            1,
1014        );
1015
1016        assert_eq!(record.token, "zero");
1017        assert_eq!(record.text_offset, 4);
1018        assert_eq!(record.top_logprobs.len(), 2);
1019        assert!(record
1020            .top_logprobs
1021            .iter()
1022            .any(|candidate| candidate.token == 0 && candidate.text == "zero"));
1023    }
1024
1025    #[test]
1026    fn stop_buffer_flushes_pending_prefix_when_generation_finishes_without_stop() {
1027        let mut buffer = StopBuffer::new(vec!["foobar".to_string()]);
1028
1029        let step = buffer.push("hello foo");
1030        assert_eq!(step.text, "hello ");
1031        assert!(!step.stopped);
1032
1033        assert_eq!(buffer.flush(), "foo");
1034    }
1035}