use crate::weights::Arch;
pub struct ToolCall<'a> {
pub name: &'a str,
pub arguments: &'a str,
}
#[derive(Default)]
pub struct ChatTurn<'a> {
pub role: &'a str,
pub content: &'a str,
pub tool_calls: Vec<ToolCall<'a>>,
}
pub fn render_chat_prompt(arch: Arch, turns: &[ChatTurn], tools_block: Option<&str>) -> String {
let mut prompt = String::new();
match arch {
Arch::Gemma3 | Arch::Gemma4 => {
let (open, close) = if arch == Arch::Gemma4 {
prompt.push_str("<bos>");
("<|turn>", "<turn|>")
} else {
("<start_of_turn>", "<end_of_turn>")
};
if let Some(block) = tools_block {
prompt.push_str(&format!("{open}user\n{block}{close}\n"));
}
for t in turns {
let role = if t.role == "assistant" {
"model"
} else {
t.role
};
prompt.push_str(&format!("{open}{role}\n{}{close}\n", t.content));
}
prompt.push_str(&format!("{open}model\n"));
}
_ => {
let first_is_system = turns.first().is_some_and(|t| t.role == "system");
if let Some(block) = tools_block
&& !first_is_system
{
prompt.push_str(&format!("<|im_start|>system\n{block}<|im_end|>\n"));
}
for (i, t) in turns.iter().enumerate() {
match t.role {
"system" => {
let mut content = t.content.to_string();
if i == 0
&& let Some(block) = tools_block
{
if !content.is_empty() {
content.push_str("\n\n");
}
content.push_str(block);
}
prompt.push_str(&format!("<|im_start|>system\n{content}<|im_end|>\n"));
}
"assistant" => {
prompt.push_str(&format!("<|im_start|>assistant\n{}", t.content));
for tc in &t.tool_calls {
let args = if tc.arguments.trim().is_empty() {
"{}"
} else {
tc.arguments
};
prompt.push_str(&format!(
"\n<tool_call>\n{{\"name\": \"{}\", \"arguments\": {}}}\n</tool_call>",
tc.name, args
));
}
prompt.push_str("<|im_end|>\n");
}
"tool" => {
let prev_is_tool = i > 0 && turns[i - 1].role == "tool";
if !prev_is_tool {
prompt.push_str("<|im_start|>user\n");
}
prompt.push_str(&format!(
"<tool_response>\n{}\n</tool_response>\n",
t.content
));
let next_is_tool = turns.get(i + 1).is_some_and(|n| n.role == "tool");
if !next_is_tool {
prompt.push_str("<|im_end|>\n");
}
}
_ => {
prompt.push_str(&format!(
"<|im_start|>{}\n{}<|im_end|>\n",
t.role, t.content
));
}
}
}
prompt.push_str("<|im_start|>assistant\n");
if arch == Arch::Qwen35 {
prompt.push_str("<think>\n\n</think>\n\n");
}
}
}
prompt
}
#[cfg(test)]
mod tests {
use super::*;
fn turn<'a>(role: &'a str, content: &'a str) -> ChatTurn<'a> {
ChatTurn {
role,
content,
..Default::default()
}
}
#[test]
fn should_render_chatml_for_qwen_family() {
let turns = [
turn("user", "hi"),
turn("assistant", "hello"),
turn("user", "bye"),
];
let p = render_chat_prompt(Arch::Qwen3, &turns, None);
assert_eq!(
p,
"<|im_start|>user\nhi<|im_end|>\n\
<|im_start|>assistant\nhello<|im_end|>\n\
<|im_start|>user\nbye<|im_end|>\n\
<|im_start|>assistant\n"
);
}
#[test]
fn should_render_gemma_turns_and_map_assistant_to_model() {
let turns = [turn("user", "hi"), turn("assistant", "hello")];
let p = render_chat_prompt(Arch::Gemma3, &turns, None);
assert_eq!(
p,
"<start_of_turn>user\nhi<end_of_turn>\n\
<start_of_turn>model\nhello<end_of_turn>\n\
<start_of_turn>model\n"
);
assert!(!p.contains("assistant"));
}
#[test]
fn should_prime_assistant_turn_for_empty_history() {
assert_eq!(
render_chat_prompt(Arch::Lfm2, &[], None),
"<|im_start|>assistant\n"
);
assert_eq!(
render_chat_prompt(Arch::Gemma4, &[], None),
"<bos><|turn>model\n"
);
}
#[test]
fn tools_block_folds_into_an_existing_system_turn() {
let turns = [turn("system", "You are helpful."), turn("user", "hi")];
let p = render_chat_prompt(Arch::Qwen3, &turns, Some("# Tools\n<tools></tools>"));
assert_eq!(
p,
"<|im_start|>system\nYou are helpful.\n\n# Tools\n<tools></tools><|im_end|>\n\
<|im_start|>user\nhi<|im_end|>\n\
<|im_start|>assistant\n"
);
}
#[test]
fn tools_block_synthesizes_a_system_turn_when_absent() {
let turns = [turn("user", "hi")];
let p = render_chat_prompt(Arch::Qwen3, &turns, Some("# Tools"));
assert_eq!(
p,
"<|im_start|>system\n# Tools<|im_end|>\n\
<|im_start|>user\nhi<|im_end|>\n\
<|im_start|>assistant\n"
);
}
#[test]
fn replays_assistant_tool_call_and_grouped_tool_results() {
let turns = [
turn("user", "weather in Paris and Rome?"),
ChatTurn {
role: "assistant",
content: "",
tool_calls: vec![
ToolCall {
name: "get_weather",
arguments: "{\"city\":\"Paris\"}",
},
ToolCall {
name: "get_weather",
arguments: "{\"city\":\"Rome\"}",
},
],
},
turn("tool", "18C"),
turn("tool", "24C"),
];
let p = render_chat_prompt(Arch::Qwen3, &turns, None);
assert_eq!(
p,
"<|im_start|>user\nweather in Paris and Rome?<|im_end|>\n\
<|im_start|>assistant\n\
\n<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\":\"Paris\"}}\n</tool_call>\
\n<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\":\"Rome\"}}\n</tool_call>\
<|im_end|>\n\
<|im_start|>user\n\
<tool_response>\n18C\n</tool_response>\n\
<tool_response>\n24C\n</tool_response>\n\
<|im_end|>\n\
<|im_start|>assistant\n"
);
}
#[test]
fn empty_tool_call_arguments_default_to_empty_object() {
let turns = [ChatTurn {
role: "assistant",
content: "",
tool_calls: vec![ToolCall {
name: "noop",
arguments: "",
}],
}];
let p = render_chat_prompt(Arch::Qwen3, &turns, None);
assert!(
p.contains("{\"name\": \"noop\", \"arguments\": {}}"),
"empty args render as {{}}: {p}"
);
}
}