use std::path::Path;
use std::sync::Mutex;
use serde_json::Value;
use crate::array::Array;
use crate::error::{Error, Result};
use crate::media::audio::{preprocess_audio_bytes, preprocess_audio_bytes_raw, ProcessedAudio};
use crate::media::image::{preprocess_image_bytes, ProcessedImage};
use crate::media::video::extract_video_frames;
use crate::models::Model;
use crate::ops;
use crate::prompt_cache::{PromptCacheConfig, PromptCachePool};
use crate::reasoning::{self, ReasoningBudget};
use crate::sampling::{Sampler, SamplingConfig};
use crate::streaming::{StreamClassifier, TokenKind};
use crate::tokenizer::ContentPart;
use crate::tokenizer::{ChatMessage, Tokenizer};
use crate::tools::{Tool, ToolCall, ToolCallFormat};
pub struct Session {
model: Model,
tokenizer: Tokenizer,
prompt_cache: Mutex<PromptCachePool>,
}
pub struct GeneratedToken {
pub id: u32,
pub text: String,
pub finished: bool,
pub kind: TokenKind,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Usage {
pub prompt_tokens: usize,
pub cached_tokens: usize,
pub completion_tokens: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum FinishReason {
#[default]
Stop,
Length,
ToolCalls,
Aborted,
}
fn classify_finish(
generated: &[u32],
eos_ids: &[u32],
has_tool_calls: bool,
aborted: bool,
) -> FinishReason {
if has_tool_calls {
FinishReason::ToolCalls
} else if generated.last().is_some_and(|id| eos_ids.contains(id)) {
FinishReason::Stop
} else if aborted {
FinishReason::Aborted
} else {
FinishReason::Length
}
}
#[derive(Debug, Clone, Default)]
pub struct GenerateReply {
pub text: String,
pub tool_calls: Vec<ToolCall>,
pub usage: Usage,
pub reasoning: Option<String>,
pub finish_reason: FinishReason,
}
#[derive(Debug, Clone, Copy)]
pub struct GenerateOptions {
pub max_tokens: usize,
pub sampling: SamplingConfig,
pub enable_thinking: Option<bool>,
pub reasoning_budget_tokens: Option<usize>,
pub prompt_cache: Option<bool>,
}
impl Default for GenerateOptions {
fn default() -> Self {
GenerateOptions {
max_tokens: 256,
sampling: SamplingConfig::default(),
enable_thinking: None,
reasoning_budget_tokens: None,
prompt_cache: None,
}
}
}
fn register_extra_eos_ids(model_dir: &Path, tokenizer: &mut Tokenizer) {
let read_json = |name: &str| -> Option<Value> {
let path = model_dir.join(name);
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
};
let collect_ids = |v: &Value, out: &mut Vec<u32>| match v {
Value::Number(n) => {
if let Some(id) = n.as_u64() {
out.push(id as u32);
}
}
Value::Array(items) => {
for item in items {
if let Some(id) = item.as_u64() {
out.push(id as u32);
}
}
}
_ => {}
};
let mut ids = Vec::new();
if let Some(config) = read_json("config.json") {
if let Some(v) = config.get("eos_token_id") {
collect_ids(v, &mut ids);
}
if let Some(v) = config
.get("text_config")
.and_then(|t| t.get("eos_token_id"))
{
collect_ids(v, &mut ids);
}
}
if let Some(gen_config) = read_json("generation_config.json") {
if let Some(v) = gen_config.get("eos_token_id") {
collect_ids(v, &mut ids);
}
}
for id in ids {
tokenizer.add_eos_id(id);
}
}
impl Session {
pub fn load(model_dir: &Path) -> Result<Self> {
Self::load_with_cache_config(model_dir, PromptCacheConfig::default())
}
pub fn load_with_cache_config(model_dir: &Path, cache_config: PromptCacheConfig) -> Result<Self> {
let model = Model::load(model_dir)?;
let mut tokenizer = Tokenizer::load(model_dir)?;
register_extra_eos_ids(model_dir, &mut tokenizer);
Ok(Session {
model,
tokenizer,
prompt_cache: Mutex::new(PromptCachePool::from_config(cache_config)),
})
}
pub fn tokenizer(&self) -> &Tokenizer {
&self.tokenizer
}
pub fn tool_call_format(&self) -> crate::tools::ToolCallFormat {
self.model.tool_call_format()
}
pub fn supports_images(&self) -> bool {
self.model.supports_images()
}
pub fn supports_audio(&self) -> bool {
self.model.supports_audio()
}
pub fn debug_new_caches(&self) -> Vec<crate::models::cache::LayerCache> {
self.model.new_caches()
}
pub fn debug_nemotron_layer_stats(&self, input_ids: &Array) -> Result<Vec<(f32, f32)>> {
self.model.debug_nemotron_layer_stats(input_ids)
}
pub fn debug_image_features(&self, bytes: &[u8]) -> Result<Vec<f32>> {
let (patch_size, max_soft_tokens, pooling_kernel_size) =
self.model.image_processing_params().unwrap();
let img = preprocess_image_bytes(bytes, patch_size, max_soft_tokens, pooling_kernel_size)?;
self.model.debug_vision_forward(&img)
}
pub fn debug_forward(
&self,
input_ids: &Array,
caches: &mut [crate::models::cache::LayerCache],
) -> Result<Array> {
self.model.forward(input_ids, caches)
}
pub fn encode_chat(&self, messages: &[ChatMessage]) -> Result<Vec<u32>> {
let prompt = self.tokenizer.apply_chat_template(messages, true)?;
self.tokenizer.encode(&prompt)
}
pub fn encode_chat_with_media(
&self,
messages: &[ChatMessage],
) -> Result<(Vec<u32>, MediaInputs)> {
self.encode_chat_with_media_tools(messages, None)
}
pub fn encode_chat_with_media_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[crate::tools::Tool]>,
) -> Result<(Vec<u32>, MediaInputs)> {
self.encode_chat_with_media_full(messages, tools, None)
}
pub fn encode_chat_with_media_full(
&self,
messages: &[ChatMessage],
tools: Option<&[crate::tools::Tool]>,
enable_thinking: Option<bool>,
) -> Result<(Vec<u32>, MediaInputs)> {
let (ids, media, _pending_reasoning) =
self.encode_chat_with_media_full_inner(messages, tools, enable_thinking)?;
Ok((ids, media))
}
fn encode_chat_with_media_full_inner(
&self,
messages: &[ChatMessage],
tools: Option<&[crate::tools::Tool]>,
enable_thinking: Option<bool>,
) -> Result<(Vec<u32>, MediaInputs, Option<(&'static str, &'static str)>)> {
let enable_thinking = enable_thinking.or(Some(false));
let prompt =
self.tokenizer
.apply_chat_template_full(messages, true, tools, enable_thinking)?;
let pending_reasoning = reasoning::pending_marker(&prompt);
let base_ids = self.tokenizer.encode(&prompt)?;
let parts: Vec<&ContentPart> = messages.iter().flat_map(|m| m.content.iter()).collect();
let has_images = parts
.iter()
.any(|p| matches!(p, ContentPart::Image(_) | ContentPart::Video(_)));
let has_audio = parts.iter().any(|p| matches!(p, ContentPart::Audio(_)));
if !has_images && !has_audio {
return Ok((base_ids, MediaInputs::default(), pending_reasoning));
}
let image_params = if has_images {
let params = self.model.image_processing_params().ok_or_else(|| {
Error::Model(
"images/videos were attached but this model has no vision support (no vision_config)".into(),
)
})?;
let ids = self.model.image_token_ids().expect(
"image_processing_params() returned Some implies image_token_ids() does too",
);
Some((params, ids))
} else {
None
};
let audio_ids = if has_audio {
Some(self.model.audio_token_ids().ok_or_else(|| {
Error::Model(
"audio was attached but this model has no audio support (no audio_config)"
.into(),
)
})?)
} else {
None
};
let video_token_id = self.model.video_token_id();
let mut image_queue: Vec<ProcessedImage> = Vec::new();
let mut video_queue: Vec<Vec<ProcessedImage>> = Vec::new();
let mut audio_queue: Vec<ProcessedAudio> = Vec::new();
for part in &parts {
match part {
ContentPart::Image(img) => {
let ((patch, max_soft, pool), _) = image_params.unwrap();
image_queue.push(preprocess_image_bytes(&img.bytes, patch, max_soft, pool)?);
}
ContentPart::Video(vid) => {
let ((patch, max_soft, pool), _) = image_params.unwrap();
let frames = extract_video_frames(&vid.bytes)?;
let mut processed = Vec::with_capacity(frames.len());
for frame in &frames {
processed.push(preprocess_image_bytes(frame, patch, max_soft, pool)?);
}
video_queue.push(processed);
}
ContentPart::Audio(aud) => {
let processed = match self.model.audio_samples_per_token() {
Some(spt) => preprocess_audio_bytes_raw(&aud.bytes, spt)?,
None => preprocess_audio_bytes(&aud.bytes)?,
};
audio_queue.push(processed);
}
ContentPart::Text(_) => {}
}
}
let mut media = MediaInputs::default();
let mut image_iter = image_queue.into_iter();
let mut video_iter = video_queue.into_iter();
let mut audio_iter = audio_queue.into_iter();
let mut expanded = Vec::with_capacity(base_ids.len() * 2);
for &t in &base_ids {
if let Some(((_, _, _), (image_token_id, boi, eoi))) = image_params {
if t == image_token_id {
if let Some(img) = image_iter.next() {
push_image_span(
&mut expanded,
img.num_soft_tokens,
image_token_id,
boi,
eoi,
);
media.images.push(img);
continue;
}
} else if video_token_id == Some(t) {
if let Some(frames) = video_iter.next() {
for frame in frames {
push_image_span(
&mut expanded,
frame.num_soft_tokens,
image_token_id,
boi,
eoi,
);
media.images.push(frame);
}
continue;
}
}
}
if let Some((audio_token_id, boa, eoa)) = audio_ids {
if t == audio_token_id {
if let Some(clip) = audio_iter.next() {
expanded.push(boa);
for _ in 0..clip.num_soft_tokens() {
expanded.push(audio_token_id);
}
expanded.push(eoa);
media.audios.push(clip);
continue;
}
}
}
expanded.push(t);
}
Ok((expanded, media, pending_reasoning))
}
pub fn generate(
&self,
prompt_ids: &[u32],
options: GenerateOptions,
on_token: impl FnMut(GeneratedToken) -> bool,
) -> Result<Vec<u32>> {
let mut caches = self.model.new_caches();
self.generate_with_caches(prompt_ids, &mut caches, options, on_token)
}
pub fn generate_with_caches(
&self,
new_prompt_ids: &[u32],
caches: &mut [crate::models::cache::LayerCache],
options: GenerateOptions,
on_token: impl FnMut(GeneratedToken) -> bool,
) -> Result<Vec<u32>> {
let mut sampler = Sampler::new(options.sampling);
let prompt_arr = Array::from_slice(new_prompt_ids, &[1, new_prompt_ids.len() as i32]);
let logits = self.model.forward(&prompt_arr, caches)?;
let next = self.sample_last(&logits, &mut sampler)?;
self.decode_loop(next, caches, sampler, options, None, on_token)
}
pub fn generate_media(
&self,
prompt_ids: &[u32],
media: &MediaInputs,
options: GenerateOptions,
on_token: impl FnMut(GeneratedToken) -> bool,
) -> Result<Vec<u32>> {
let mut caches = self.model.new_caches();
self.generate_with_media(prompt_ids, media, &mut caches, options, on_token)
}
pub fn generate_with_media(
&self,
new_prompt_ids: &[u32],
media: &MediaInputs,
caches: &mut [crate::models::cache::LayerCache],
options: GenerateOptions,
on_token: impl FnMut(GeneratedToken) -> bool,
) -> Result<Vec<u32>> {
self.generate_with_media_inner(new_prompt_ids, media, caches, options, None, on_token)
}
fn generate_with_media_inner(
&self,
new_prompt_ids: &[u32],
media: &MediaInputs,
caches: &mut [crate::models::cache::LayerCache],
options: GenerateOptions,
pending_reasoning: Option<(&'static str, &'static str)>,
on_token: impl FnMut(GeneratedToken) -> bool,
) -> Result<Vec<u32>> {
let mut sampler = Sampler::new(options.sampling);
let prompt_arr = Array::from_slice(new_prompt_ids, &[1, new_prompt_ids.len() as i32]);
let logits = if media.is_empty() {
self.model.forward(&prompt_arr, caches)?
} else {
self.model
.forward_with_media(&prompt_arr, &media.images, &media.audios, caches)?
};
let next = self.sample_last(&logits, &mut sampler)?;
self.decode_loop(next, caches, sampler, options, pending_reasoning, on_token)
}
fn decode_loop(
&self,
mut next: u32,
caches: &mut [crate::models::cache::LayerCache],
mut sampler: Sampler,
options: GenerateOptions,
pending_reasoning: Option<(&'static str, &'static str)>,
mut on_token: impl FnMut(GeneratedToken) -> bool,
) -> Result<Vec<u32>> {
let eos_ids = self.tokenizer.eos_token_ids();
let mut budget = options.reasoning_budget_tokens.map(ReasoningBudget::new);
let mut classifier = StreamClassifier::new(self.tool_call_format());
if let Some(pair @ (_, close)) = pending_reasoning {
classifier.seed_reasoning(close);
if let Some(b) = budget.as_mut() {
b.seed_open(pair);
}
}
let mut generated = Vec::with_capacity(options.max_tokens);
for _ in 0..options.max_tokens {
let finished = eos_ids.contains(&next);
let text = self.tokenizer.decode_piece(next)?;
let raw_text = self.tokenizer.decode_piece_raw(next)?;
generated.push(next);
let kind = classifier.classify(&raw_text);
let keep_going = on_token(GeneratedToken {
id: next,
text: stream_display_text(&classifier, text.clone()),
finished,
kind,
});
if finished || !keep_going {
break;
}
let forced_close = budget.as_mut().and_then(|b| b.observe(&raw_text));
if let Some(close_marker) = forced_close {
let close_ids = self.tokenizer.encode(close_marker).unwrap_or_default();
if close_ids.is_empty() {
let next_arr = Array::from_slice(&[next], &[1, 1]);
let logits = self.model.forward(&next_arr, caches)?;
next = self.sample_last(&logits, &mut sampler)?;
continue;
}
let close_arr = Array::from_slice(&close_ids, &[1, close_ids.len() as i32]);
let logits = self.model.forward(&close_arr, caches)?;
let mut stopped = false;
for &id in &close_ids {
generated.push(id);
let piece = self.tokenizer.decode_piece(id)?;
let raw_piece = self.tokenizer.decode_piece_raw(id)?;
let kind = classifier.classify(&raw_piece);
if !on_token(GeneratedToken {
id,
text: stream_display_text(&classifier, piece),
finished: false,
kind,
}) {
stopped = true;
break;
}
}
if stopped {
break;
}
next = self.sample_last(&logits, &mut sampler)?;
continue;
}
let next_arr = Array::from_slice(&[next], &[1, 1]);
let logits = self.model.forward(&next_arr, caches)?;
next = self.sample_last(&logits, &mut sampler)?;
}
Ok(generated)
}
pub(crate) fn new_caches(&self) -> Vec<crate::models::cache::LayerCache> {
self.model.new_caches()
}
pub fn generate_cached(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
options: GenerateOptions,
mut on_token: impl FnMut(GeneratedToken) -> bool,
) -> Result<GenerateReply> {
let (full_ids, media, pending_reasoning) =
self.encode_chat_with_media_full_inner(messages, tools, options.enable_thinking)?;
let cache_enabled = options.prompt_cache.unwrap_or(true);
let (mut caches, fed_len, fed_images, fed_audios) = if cache_enabled {
let mut pool = self.prompt_cache.lock().unwrap();
match pool.find_longest_prefix(&full_ids) {
Some((entry, shared)) => (entry.caches, shared, entry.fed_images, entry.fed_audios),
None => (self.new_caches(), 0, 0, 0),
}
} else {
(self.new_caches(), 0, 0, 0)
};
let new_suffix = &full_ids[fed_len..];
let new_media = MediaInputs {
images: media.images[fed_images.min(media.images.len())..].to_vec(),
audios: media.audios[fed_audios.min(media.audios.len())..].to_vec(),
};
let mut generated_ids = Vec::new();
let mut aborted = false;
let ids = self.generate_with_media_inner(
new_suffix,
&new_media,
&mut caches,
options,
pending_reasoning,
|tok| {
generated_ids.push(tok.id);
let keep_going = on_token(tok);
if !keep_going {
aborted = true;
}
keep_going
},
)?;
debug_assert_eq!(ids, generated_ids);
let usage = Usage {
prompt_tokens: full_ids.len(),
cached_tokens: fed_len,
completion_tokens: generated_ids.len(),
};
if cache_enabled {
let mut cached_ids = full_ids;
cached_ids.extend_from_slice(&generated_ids);
self.prompt_cache.lock().unwrap().insert_or_update(
cached_ids,
caches,
media.images.len(),
media.audios.len(),
false,
);
}
let eos_ids = self.tokenizer.eos_token_ids();
let content_ids: Vec<u32> = generated_ids
.iter()
.copied()
.filter(|id| !eos_ids.contains(id))
.collect();
let raw_text = self.tokenizer.decode_raw(&content_ids)?;
let (reasoning, text) = match pending_reasoning {
Some((open, _)) => reasoning::split_reasoning(&format!("{open}{raw_text}")),
None => reasoning::split_reasoning(&raw_text),
};
let format = self.tool_call_format();
let (text, calls) = if matches!(format, ToolCallFormat::None) {
(text, Vec::new())
} else {
let calls = crate::tools::parse_tool_calls(&text, format);
(crate::tools::strip_tool_calls(&text, format), calls)
};
let finish_reason =
classify_finish(&generated_ids, eos_ids, !calls.is_empty(), aborted);
Ok(GenerateReply {
text,
tool_calls: calls,
usage,
reasoning,
finish_reason,
})
}
fn sample_last(&self, logits: &Array, sampler: &mut Sampler) -> Result<u32> {
let shape = logits.shape();
let seq_len = shape[1];
let last = ops::slice(logits, &[0, seq_len - 1, 0], &[shape[0], seq_len, shape[2]])?;
let last = ops::reshape(&last, &[shape[2]])?;
sampler.sample(&last)
}
}
#[derive(Debug, Clone, Default)]
pub struct MediaInputs {
pub images: Vec<ProcessedImage>,
pub audios: Vec<ProcessedAudio>,
}
impl MediaInputs {
pub fn is_empty(&self) -> bool {
self.images.is_empty() && self.audios.is_empty()
}
}
fn stream_display_text(classifier: &StreamClassifier, text: String) -> String {
match classifier.last_marker() {
Some(marker) => {
let trimmed = text.trim();
if !trimmed.is_empty() && marker.contains(trimmed) {
String::new()
} else {
text
}
}
None => text,
}
}
#[cfg(test)]
mod tests {
use super::*;
const EOS: &[u32] = &[2, 106];
#[test]
fn finish_stop_on_trailing_eos() {
assert_eq!(
classify_finish(&[5, 9, 2], EOS, false, false),
FinishReason::Stop
);
}
#[test]
fn finish_tool_calls_takes_precedence_over_eos() {
assert_eq!(
classify_finish(&[5, 9, 2], EOS, true, false),
FinishReason::ToolCalls
);
}
#[test]
fn finish_length_when_no_eos_and_not_aborted() {
assert_eq!(
classify_finish(&[5, 9, 7], EOS, false, false),
FinishReason::Length
);
}
#[test]
fn finish_aborted_when_callback_stopped_early() {
assert_eq!(
classify_finish(&[5, 9, 7], EOS, false, true),
FinishReason::Aborted
);
}
#[test]
fn finish_empty_generation_without_abort_is_length() {
assert_eq!(classify_finish(&[], EOS, false, false), FinishReason::Length);
}
}
fn push_image_span(
out: &mut Vec<u32>,
num_soft_tokens: i32,
image_token_id: u32,
boi: u32,
eoi: u32,
) {
out.push(boi);
for _ in 0..num_soft_tokens {
out.push(image_token_id);
}
out.push(eoi);
}