use std::collections::HashSet;
use tokenizer::{
ChatDialect, MfTokenizer, ParsedToolCall, ReasoningEffort, StructuredAssistantDecoder,
StructuredAssistantEvent, ToolCallParserError, NO_SUCH_TOKEN_ID,
};
use crate::raw_completion::RawDecodeProgress;
#[derive(Debug, Clone, PartialEq)]
pub enum TurnEvent {
Prefill {
done: usize,
total: usize,
},
Content(String),
ReleasedToolSpan(String),
Reasoning(String),
ToolCall(ParsedToolCall),
}
pub struct TurnSplitter<'a> {
decoder: Option<StructuredAssistantDecoder<'a>>,
degraded: bool,
}
impl<'a> TurnSplitter<'a> {
pub fn new(
tokenizer: &'a MfTokenizer,
tools: &HashSet<String>,
effort: ReasoningEffort,
id_generator: impl FnMut() -> String + 'a,
prompt_ids: &[i32],
) -> Self {
let wanted = !tools.is_empty()
|| matches!(
tokenizer.dialect,
ChatDialect::Harmony | ChatDialect::MuseGlimmer | ChatDialect::MiniMax
)
|| (effort != ReasoningEffort::Off
&& matches!(
tokenizer.dialect,
ChatDialect::ChatMl | ChatDialect::Gemma | ChatDialect::Spark
))
|| (effort != ReasoningEffort::Off
&& matches!(tokenizer.dialect, ChatDialect::Glm | ChatDialect::Kimi));
Self {
decoder: wanted.then(|| {
StructuredAssistantDecoder::new(tokenizer, tools.clone(), id_generator, prompt_ids)
}),
degraded: false,
}
}
pub fn degraded(&self) -> bool {
self.degraded
}
pub fn feed(&mut self, event: RawDecodeProgress, out: &mut impl FnMut(TurnEvent)) {
let (id, text) = match event {
RawDecodeProgress::Prefill { done, total } => {
out(TurnEvent::Prefill { done, total });
return;
}
RawDecodeProgress::Token { id, delta, .. } => (id, delta),
RawDecodeProgress::Tail(tail) => (NO_SUCH_TOKEN_ID, tail),
};
let Some(decoder) = self.decoder.as_mut().filter(|_| !self.degraded) else {
if !text.is_empty() {
out(TurnEvent::Content(text));
}
return;
};
match decoder.consume(id, &text) {
Ok(events) => out_structured(events, out),
Err(_) => {
self.degraded = true;
if let Some(body) = decoder.take_failed_span_text() {
out(TurnEvent::ReleasedToolSpan(body));
}
if !text.is_empty() {
out(TurnEvent::Content(text));
}
}
}
}
pub fn finish(&mut self, out: &mut impl FnMut(TurnEvent)) -> Result<(), ToolCallParserError> {
let Some(decoder) = self.decoder.as_mut().filter(|_| !self.degraded) else {
return Ok(());
};
match decoder.finish() {
Ok(events) => {
out_structured(events, out);
Ok(())
}
Err(e) => {
if let Some(body) = decoder.take_failed_span_text() {
out(TurnEvent::ReleasedToolSpan(body));
}
Err(e)
}
}
}
#[cfg(test)]
fn decodes(&self) -> bool {
self.decoder.is_some()
}
}
fn out_structured(events: Vec<StructuredAssistantEvent>, out: &mut impl FnMut(TurnEvent)) {
for event in events {
out(match event {
StructuredAssistantEvent::Content(c) => TurnEvent::Content(c),
StructuredAssistantEvent::Reasoning(r) => TurnEvent::Reasoning(r),
StructuredAssistantEvent::ToolCall(c) => TurnEvent::ToolCall(c),
});
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::path::PathBuf;
use super::{TurnEvent, TurnSplitter};
use crate::raw_completion::RawDecodeProgress;
use tokenizer::{MfTokenizer, ReasoningEffort};
fn fixture(name: &str) -> MfTokenizer {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tokenizer/tests/fixtures")
.join(name);
MfTokenizer::load_from_dir(&dir).expect("fixture tokenizer should load")
}
fn splitter<'a>(
tok: &'a MfTokenizer,
tools: &[&str],
effort: ReasoningEffort,
) -> TurnSplitter<'a> {
let tools: HashSet<String> = tools.iter().map(|s| s.to_string()).collect();
TurnSplitter::new(tok, &tools, effort, String::new, &[])
}
fn feed_all(
splitter: &mut TurnSplitter,
events: &mut Vec<TurnEvent>,
inputs: &[RawDecodeProgress],
) {
for input in inputs {
splitter.feed(input.clone(), &mut |e| events.push(e));
}
}
fn delta(id: i32, text: &str) -> RawDecodeProgress {
RawDecodeProgress::Token {
index: 0,
id,
delta: text.to_string(),
}
}
#[test]
fn minimax_always_splits_its_forced_thought() {
let mut tok = fixture("ChatMLTokenizer");
tok.dialect = tokenizer::ChatDialect::MiniMax;
let start = tok.think_start_id.unwrap();
let end = tok.think_end_id.unwrap();
let mut split = TurnSplitter::new(
&tok,
&HashSet::new(),
ReasoningEffort::Off,
String::new,
&[start],
);
let mut events = Vec::new();
feed_all(
&mut split,
&mut events,
&[delta(-1, "reasoning"), delta(end, ""), delta(-1, "answer")],
);
assert_eq!(
events,
[
TurnEvent::Reasoning("reasoning".into()),
TurnEvent::Content("answer".into())
]
);
}
#[test]
fn the_decoder_decision_is_a_table_over_dialect_request_and_tools() {
let harmony = fixture("HarmonyTokenizer");
let muse = fixture("MuseGlimmerTokenizer");
let chatml = fixture("ChatMLTokenizer");
let gemma = fixture("GemmaTokenizer");
let deepseek = fixture("DeepseekTokenizer");
assert!(splitter(&harmony, &[], ReasoningEffort::Off).decodes());
assert!(splitter(&muse, &[], ReasoningEffort::Off).decodes());
assert!(!splitter(&chatml, &[], ReasoningEffort::Off).decodes());
assert!(splitter(&chatml, &[], ReasoningEffort::Low).decodes());
assert!(!splitter(&gemma, &[], ReasoningEffort::Off).decodes());
assert!(splitter(&gemma, &[], ReasoningEffort::Low).decodes());
assert!(!splitter(&deepseek, &[], ReasoningEffort::Low).decodes());
assert!(splitter(&chatml, &["f"], ReasoningEffort::Off).decodes());
assert!(splitter(&gemma, &["f"], ReasoningEffort::Off).decodes());
assert!(splitter(&deepseek, &["f"], ReasoningEffort::Off).decodes());
}
#[test]
fn empty_deltas_reach_the_decoder_and_frame_it() {
let tok = fixture("HarmonyTokenizer");
let mut s = splitter(&tok, &[], ReasoningEffort::Off);
let mut events = Vec::new();
feed_all(
&mut s,
&mut events,
&[
delta(tok.channel_start_id, ""),
delta(-6, "analysis"),
delta(tok.message_start_id, ""),
delta(-7, "thinking"),
],
);
assert_eq!(events, vec![TurnEvent::Reasoning("thinking".to_string())]);
}
#[test]
fn without_a_decoder_text_passes_through_and_empty_deltas_stay_dropped() {
let tok = fixture("ChatMLTokenizer");
let mut s = splitter(&tok, &[], ReasoningEffort::Off);
assert!(!s.decodes());
let mut events = Vec::new();
feed_all(
&mut s,
&mut events,
&[delta(11, ""), delta(12, "hel"), delta(13, "lo")],
);
s.feed(RawDecodeProgress::Tail(" tail".to_string()), &mut |e| {
events.push(e)
});
assert_eq!(
events,
vec![
TurnEvent::Content("hel".to_string()),
TurnEvent::Content("lo".to_string()),
TurnEvent::Content(" tail".to_string()),
]
);
}
#[test]
fn prefill_passes_through_whether_or_not_a_decoder_was_built() {
let tok = fixture("ChatMLTokenizer");
for effort in [ReasoningEffort::Off, ReasoningEffort::High] {
let mut s = splitter(&tok, &[], effort);
let mut events = Vec::new();
s.feed(RawDecodeProgress::Prefill { done: 3, total: 9 }, &mut |e| {
events.push(e)
});
assert_eq!(events, vec![TurnEvent::Prefill { done: 3, total: 9 }]);
}
}
#[test]
fn a_decoder_error_degrades_the_rest_of_the_turn_to_raw_text() {
let tok = fixture("GemmaTokenizer");
let start = tok.tool_call_start_id;
let mut s = splitter(&tok, &[], ReasoningEffort::Low);
assert!(s.decodes());
let mut events = Vec::new();
feed_all(
&mut s,
&mut events,
&[
delta(start, ""),
delta(start, ""),
delta(21, "raw "),
delta(22, "text"),
],
);
assert_eq!(
events,
vec![
TurnEvent::Content("raw ".to_string()),
TurnEvent::Content("text".to_string()),
]
);
assert!(s.degraded());
}
#[test]
fn a_degraded_finish_emits_nothing() {
let tok = fixture("GemmaTokenizer");
let start = tok.tool_call_start_id;
let mut s = splitter(&tok, &[], ReasoningEffort::Low);
s.feed(delta(start, ""), &mut |_| {});
s.feed(delta(start, ""), &mut |_| {});
assert!(s.degraded());
let mut sink = Vec::new();
assert!(s.finish(&mut |e| sink.push(e)).is_ok());
assert!(sink.is_empty());
}
#[test]
fn a_malformed_span_releases_its_body_as_released_tool_span() {
let tok = fixture("ChatMLTokenizer");
let allowed: HashSet<String> = ["f".to_string()].into_iter().collect();
let mut s = TurnSplitter::new(&tok, &allowed, ReasoningEffort::Off, String::new, &[]);
assert!(s.decodes());
let body = r#"{"name": "f", "arguments": {"x": "1"}}"#;
let body_ids = tok.encode(body, false);
let mut events = Vec::new();
feed_all(
&mut s,
&mut events,
&std::iter::once(delta(tok.tool_call_start_id, ""))
.chain(body_ids.iter().map(|&id| delta(id, "")))
.chain(std::iter::once(delta(tok.tool_call_end_id, "")))
.chain([delta(-6, " then "), delta(-7, "prose")])
.collect::<Vec<_>>(),
);
assert!(s.degraded());
assert_eq!(
events,
vec![
TurnEvent::ReleasedToolSpan(body.to_string()),
TurnEvent::Content(" then ".to_string()),
TurnEvent::Content("prose".to_string()),
]
);
}
#[test]
fn a_decoder_free_splitter_finish_is_a_no_op() {
let tok = fixture("ChatMLTokenizer");
let mut s = splitter(&tok, &[], ReasoningEffort::Off);
let mut sink = Vec::new();
assert!(s.finish(&mut |e| sink.push(e)).is_ok());
assert!(sink.is_empty());
}
}