use std::path::Path;
use serde_json::Value;
use crate::tasks::chat_template::ChatTemplate;
use crate::tasks::generate::{ThinkingBlock, ThinkingMode, ToolCall};
const MIN_SPLIT_MAX_TOKENS: usize = 512;
const CLOSE_THOUGHT: &str = "\n\nConsidering the limited time by the user, I have to give the \
solution based on the thinking directly now.\n</think>\n\n";
#[derive(Debug)]
pub(crate) struct ThinkingSplit {
prompt: String,
pub(crate) budget: usize,
max_tokens: usize,
}
#[derive(Debug, PartialEq)]
pub(crate) struct Continuation {
pub(crate) prompt: String,
pub(crate) max_tokens: usize,
pub(crate) thought: Option<String>,
pub(crate) answer_so_far: String,
}
impl ThinkingSplit {
pub(crate) fn plan(
model_dir: &Path,
body: &Value,
max_tokens: usize,
budget_tokens: usize,
thinking: ThinkingMode,
structured_output: bool,
) -> Option<Self> {
if structured_output || max_tokens < MIN_SPLIT_MAX_TOKENS || !model_dir.is_dir() {
return None;
}
let template = match ChatTemplate::load(model_dir) {
Ok(Some(template)) => template,
Ok(None) => return None,
Err(error) => {
tracing::debug!(%error, dir = %model_dir.display(), "no thinking budget: chat template unusable");
return None;
}
};
let wire_messages = body.get("messages")?.as_array()?;
let has_media = wire_messages.iter().any(|message| {
message
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| {
parts
.iter()
.any(|part| part.get("type").and_then(Value::as_str) != Some("text"))
})
});
if has_media {
return None;
}
let messages = served_messages(wire_messages);
let tools = body
.get("tools")
.and_then(Value::as_array)
.map(Vec::as_slice);
let prompt = match template.render_values(&messages, tools, thinking) {
Ok(prompt) => prompt,
Err(error) => {
tracing::debug!(%error, "no thinking budget: chat template did not render");
return None;
}
};
if !prompt.ends_with("<think>\n") {
return None;
}
let budget = if budget_tokens > 0 && budget_tokens < max_tokens {
budget_tokens
} else {
max_tokens / 2
};
Some(Self {
prompt,
budget,
max_tokens,
})
}
pub(crate) fn continuation(
&self,
text: &str,
thinking: &[ThinkingBlock],
tool_calls: &[ToolCall],
completion_tokens: u64,
) -> Option<Continuation> {
let spent = completion_tokens as usize;
if !tool_calls.is_empty() || spent < self.budget {
return None;
}
let max_tokens = self.max_tokens.checked_sub(spent).filter(|n| *n > 0)?;
let reasoning: String = thinking.iter().map(|block| block.text.as_str()).collect();
if reasoning.is_empty() {
if text.contains("</think>") {
tracing::warn!("vllm-mlx returned `</think>` unsplit; no thinking continuation");
return None;
}
Some(Continuation {
prompt: format!("{}{}{}", self.prompt, text, CLOSE_THOUGHT),
max_tokens,
thought: Some(text.to_string()),
answer_so_far: String::new(),
})
} else {
Some(Continuation {
prompt: format!("{}{}\n</think>\n\n{}", self.prompt, reasoning, text),
max_tokens,
thought: None,
answer_so_far: text.to_string(),
})
}
}
}
fn served_messages(messages: &[Value]) -> Vec<Value> {
messages
.iter()
.map(|message| {
let mut message = message.clone();
if let Some(object) = message.as_object_mut() {
let content = match object.get("content") {
None | Some(Value::Null) => Value::String(String::new()),
Some(Value::Array(parts)) => Value::String(
parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
),
Some(other) => other.clone(),
};
object.insert("content".into(), content);
if let Some(Value::Array(calls)) = object.get_mut("tool_calls") {
for call in calls {
if let Some(arguments) = call.pointer_mut("/function/arguments") {
if let Some(parsed) = arguments
.as_str()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
{
*arguments = parsed;
}
}
}
}
}
message
})
.collect()
}
#[cfg(test)]
pub(crate) const THINKING_TEMPLATE: &str = r#"{%- for m in messages %}{{- '<|im_start|>' + m.role + '\n' + m.content + '<|im_end|>\n' }}{%- endfor %}{%- if add_generation_prompt %}{{- '<|im_start|>assistant\n' }}{%- if enable_thinking is defined and enable_thinking is false %}{{- '<think>\n\n</think>\n\n' }}{%- else %}{{- '<think>\n' }}{%- endif %}{%- endif %}"#;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn model_dir(template: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("chat_template.jinja"), template).unwrap();
dir
}
fn body() -> Value {
json!({"model": "m", "messages": [{"role": "user", "content": "hi"}]})
}
fn block(text: &str) -> ThinkingBlock {
ThinkingBlock {
text: text.into(),
signature: None,
redacted_data: None,
}
}
#[test]
fn a_think_opening_template_splits_the_budget() {
let dir = model_dir(THINKING_TEMPLATE);
let split =
ThinkingSplit::plan(dir.path(), &body(), 1024, 0, ThinkingMode::Auto, false).unwrap();
assert_eq!(split.budget, 512);
assert!(split.prompt.ends_with("<|im_start|>assistant\n<think>\n"));
let split =
ThinkingSplit::plan(dir.path(), &body(), 1024, 300, ThinkingMode::Auto, false).unwrap();
assert_eq!(split.budget, 300);
}
#[test]
fn thinking_off_closes_the_block_and_is_not_split() {
let dir = model_dir(THINKING_TEMPLATE);
assert!(
ThinkingSplit::plan(dir.path(), &body(), 1024, 0, ThinkingMode::Off, false).is_none()
);
assert!(
ThinkingSplit::plan(dir.path(), &body(), 1024, 0, ThinkingMode::On, false).is_some()
);
}
#[test]
fn no_split_where_it_cannot_apply() {
let plain = model_dir(
r#"{%- for m in messages %}{{ m.content }}{%- endfor %}{{- '<|im_start|>assistant\n' }}"#,
);
assert!(
ThinkingSplit::plan(plain.path(), &body(), 1024, 0, ThinkingMode::Auto, false)
.is_none()
);
let dir = model_dir(THINKING_TEMPLATE);
assert!(
ThinkingSplit::plan(dir.path(), &body(), 256, 0, ThinkingMode::Auto, false).is_none()
);
assert!(
ThinkingSplit::plan(dir.path(), &body(), 1024, 0, ThinkingMode::Auto, true).is_none()
);
assert!(ThinkingSplit::plan(
Path::new("org/model"),
&body(),
1024,
0,
ThinkingMode::Auto,
false
)
.is_none());
}
#[test]
fn a_conversation_with_an_image_is_not_split() {
let dir = model_dir(THINKING_TEMPLATE);
let with_image = json!({"messages": [{"role": "user", "content": [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,aGVsbG8="}}
]}]});
assert!(
ThinkingSplit::plan(dir.path(), &with_image, 1024, 0, ThinkingMode::Auto, false)
.is_none()
);
let text_parts = json!({"messages": [{"role": "user", "content": [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"}
]}]});
let split =
ThinkingSplit::plan(dir.path(), &text_parts, 1024, 0, ThinkingMode::Auto, false)
.unwrap();
assert!(split.prompt.contains("first\nsecond"));
}
fn split() -> (tempfile::TempDir, ThinkingSplit) {
let dir = model_dir(THINKING_TEMPLATE);
let split =
ThinkingSplit::plan(dir.path(), &body(), 1000, 0, ThinkingMode::Auto, false).unwrap();
(dir, split)
}
#[test]
fn a_thought_cut_off_at_the_budget_is_closed_and_answered() {
let (_dir, split) = split();
let next = split
.continuation("We need to add 2 and 2", &[], &[], 502)
.unwrap();
assert_eq!(next.thought.as_deref(), Some("We need to add 2 and 2"));
assert_eq!(next.max_tokens, 498);
assert!(next
.prompt
.ends_with(&format!("<think>\nWe need to add 2 and 2{CLOSE_THOUGHT}")));
assert!(next.answer_so_far.is_empty());
}
#[test]
fn an_answer_cut_off_at_the_budget_is_continued() {
let (_dir, split) = split();
let next = split
.continuation("The answer is", &[block("2 + 2 = 4")], &[], 500)
.unwrap();
assert_eq!(next.thought, None);
assert_eq!(next.answer_so_far, "The answer is");
assert!(next
.prompt
.ends_with("<think>\n2 + 2 = 4\n</think>\n\nThe answer is"));
}
#[test]
fn a_response_inside_its_budget_or_calling_a_tool_needs_no_second_request() {
let (_dir, split) = split();
assert_eq!(split.continuation("4", &[block("2 + 2")], &[], 499), None);
let call = ToolCall {
id: None,
name: "f".into(),
arguments: Default::default(),
};
assert_eq!(split.continuation("x", &[], &[call], 500), None);
assert_eq!(split.continuation("a</think>b", &[], &[], 500), None);
assert_eq!(split.continuation("thinking", &[], &[], 1000), None);
}
#[test]
fn served_messages_match_what_vllm_mlx_renders() {
let messages = served_messages(&[
json!({"role": "assistant", "content": null, "tool_calls": [
{"id": "c", "type": "function", "function": {"name": "f", "arguments": "{\"k\": 1}"}}
]}),
json!({"role": "user", "content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]}),
]);
assert_eq!(messages[0]["content"], json!(""));
assert_eq!(
messages[0]["tool_calls"][0]["function"]["arguments"],
json!({"k": 1})
);
assert_eq!(messages[1]["content"], json!("a\nb"));
}
}