use axum::http::StatusCode;
use axum::response::Response;
use std::sync::Arc;
use crate::AppState;
use super::super::compact::{compact_messages, openai_error_response};
use super::msg_entry::MsgEntry;
pub(super) struct TemplateOut {
pub(super) prompt_tokens: Vec<u32>,
pub(super) enable_thinking: bool,
pub(super) thinking_budget: Option<u32>,
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::result_large_err)]
pub(super) fn render_template(
state: &Arc<AppState>,
tools: &[crate::tool_parser::ToolDefinition],
messages: &[MsgEntry],
image_pad_counts: &[usize],
enable_thinking: bool,
thinking_budget: Option<u32>,
reasoning_effort: Option<crate::ir::ReasoningEffort>,
thinking_vars: crate::tokenizer::ThinkingVars,
tools_active: bool,
) -> Result<TemplateOut, Response> {
let template_thinking = enable_thinking;
let json_messages =
build_json_messages_for(messages, state.tokenizer.uses_deepseek_v4_encoding());
let jinja_tools: Option<Vec<serde_json::Value>> = if tools_active
&& (state.tokenizer.uses_deepseek_v4_encoding() || !state.chat.prompt.tscg)
{
Some(
tools
.iter()
.map(|t| serde_json::to_value(t).unwrap_or_default())
.collect(),
)
} else {
None
};
let auto_compact_active = state
.auto_compact_threshold
.map(|t| t > 0.0)
.unwrap_or(false);
let json_messages = if auto_compact_active && json_messages.len() > 4 {
let trial_tokens = state
.tokenizer
.apply_chat_template_openai_with_effort(
&json_messages,
jinja_tools.as_deref(),
template_thinking,
state.behavior.disable_tool_steering,
reasoning_effort.map(crate::ir::ReasoningEffort::as_str),
thinking_vars,
)
.map(|t| t.len())
.unwrap_or(0);
if trial_tokens > (state.max_seq_len as f32 * 0.70) as usize {
compact_messages(&json_messages, trial_tokens, state.max_seq_len)
} else {
json_messages
}
} else {
json_messages
};
let prompt_tokens = match state.tokenizer.apply_chat_template_openai_with_effort(
&json_messages,
jinja_tools.as_deref(),
template_thinking,
state.behavior.disable_tool_steering,
reasoning_effort.map(crate::ir::ReasoningEffort::as_str),
thinking_vars,
) {
Ok(t) => t,
Err(e) => {
return Err(openai_error_response(
StatusCode::BAD_REQUEST,
format!("Tokenization error: {e}"),
));
}
};
let prompt_tokens = if image_pad_counts.iter().any(|&c| c > 1) {
let declared = state
.vision_config
.as_ref()
.map(|v| (v.image_pad_token_id, v.video_pad_token_id))
.unwrap_or((0, 0));
state
.tokenizer
.expand_vision_pads(prompt_tokens, image_pad_counts, declared)
} else {
prompt_tokens
};
let (enable_thinking, thinking_budget) = if let Some(think_start) = state.think_start_token_id {
let tail = &prompt_tokens[prompt_tokens.len().saturating_sub(8)..];
let last_start = tail.iter().rposition(|t| *t == think_start);
let has_unclosed_think = match (last_start, state.think_end_token_id) {
(Some(si), Some(end_tok)) => !tail[si + 1..].contains(&end_tok),
(Some(_), None) => true,
(None, _) => false,
};
if has_unclosed_think && !enable_thinking {
let budget = (!state.uncapped_thinking).then_some(state.behavior.max_thinking_budget);
tracing::info!(
"Template-forced thinking detected (unclosed \\<think\\> in prompt tail) — \
overriding enable_thinking=true with budget={budget:?}",
);
(true, budget)
} else {
(enable_thinking, thinking_budget)
}
} else {
(enable_thinking, thinking_budget)
};
Ok(TemplateOut {
prompt_tokens,
enable_thinking,
thinking_budget,
})
}
pub(super) fn build_json_messages(messages: &[MsgEntry]) -> Vec<serde_json::Value> {
build_json_messages_for(messages, false)
}
fn build_json_messages_for(
messages: &[MsgEntry],
include_tool_call_ids: bool,
) -> Vec<serde_json::Value> {
messages
.iter()
.map(|m| {
let content_val = if !m.media.is_empty() {
let mut items: Vec<serde_json::Value> = Vec::with_capacity(m.media.len() + 1);
for kind in &m.media {
items.push(match kind {
crate::ir::MediaKind::Image => serde_json::json!({"type": "image"}),
crate::ir::MediaKind::Video => serde_json::json!({"type": "video"}),
});
}
if !m.content.is_empty() {
items.push(serde_json::json!({"type": "text", "text": m.content}));
}
serde_json::Value::Array(items)
} else {
serde_json::Value::String(m.content.clone())
};
let mut msg = serde_json::json!({"role": m.role, "content": content_val});
if let Some(ref tcs) = m.tool_calls {
msg["tool_calls"] = serde_json::Value::Array(tcs.clone());
}
if include_tool_call_ids && let Some(ref id) = m.tool_call_id {
msg["tool_call_id"] = serde_json::Value::String(id.clone());
}
if let Some(ref rc) = m.reasoning_content {
msg["reasoning_content"] = serde_json::Value::String(rc.clone());
}
msg
})
.collect()
}
#[cfg(test)]
mod json_message_tests {
use super::MsgEntry;
use super::build_json_messages;
fn entry(role: &str, content: &str, image_count: usize) -> MsgEntry {
MsgEntry {
role: role.to_string(),
content: content.to_string(),
tool_calls: None,
tool_call_id: None,
media: vec![crate::ir::MediaKind::Image; image_count],
reasoning_content: None,
}
}
#[test]
fn prompt_json_stability_gate() {
use crate::ir::message::{Reasoning, ToolCall};
use crate::ir::{ContentPart, Message, Role};
fn text_msg(role: Role, t: &str) -> Message {
Message {
role,
content: vec![ContentPart::Text(t.into())],
tool_calls: Vec::new(),
tool_call_id: None,
name: None,
reasoning: None,
tool_error: false,
}
}
let mut assistant = text_msg(Role::Assistant, "Sure.");
assistant.reasoning = Some(Reasoning {
text: "plan the verification".into(),
});
assistant.tool_calls = vec![
ToolCall {
id: "c1".into(),
name: "bash".into(),
arguments: serde_json::json!({"command": "cargo test"}),
},
ToolCall {
id: "c2".into(),
name: "read".into(),
arguments: serde_json::json!({"path": "a.rs"}),
},
];
let mut tool_result = text_msg(Role::Tool, "total 0");
tool_result.tool_call_id = Some("c1".into());
tool_result.name = Some("bash".into());
let msgs = vec![
text_msg(
Role::System,
"You are helpful.\nworking directory: /tmp/proj",
),
text_msg(Role::Other("developer".into()), "be terse"),
text_msg(Role::User, "run the tests"),
assistant,
tool_result,
text_msg(Role::User, "thanks"),
];
let out = super::super::msg_entry::build_msg_entries(
None,
None,
&crate::api::chat::remote_image::RemoteImagePolicy::default(),
&crate::api::chat::msg_entry::VideoDecode {
ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
enabled: false,
..Default::default()
},
fps: 2.0,
},
&msgs,
true,
&crate::api::chat::levers::ChatLevers::OFF,
false,
)
.expect("fixture builds");
assert_eq!(out.cwd_hint.as_deref(), Some("/tmp/proj"));
let json = build_json_messages(&out.messages);
let expected = serde_json::json!([
{
"role": "system",
"content": "You are helpful.\nworking directory: /tmp/proj\n<environment>\nworking_directory: /tmp/proj\n</environment>"
},
{"role": "system", "content": "be terse"},
{"role": "user", "content": "run the tests"},
{
"role": "assistant",
"content": "Sure.",
"tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "bash", "arguments": {"command": "cargo test"}}},
{"id": "c2", "type": "function", "function": {"name": "read", "arguments": {"path": "a.rs"}}}
],
"reasoning_content": "plan the verification"
},
{"role": "tool", "content": "total 0"},
{"role": "user", "content": "thanks"}
]);
assert_eq!(
serde_json::Value::Array(json.clone()),
expected,
"prompt JSON drifted — this breaks kv-cache prefix stability:\n{}",
serde_json::to_string_pretty(&json).unwrap()
);
}
#[test]
fn plain_text_message_serializes_to_string_content() {
let out = build_json_messages(&[entry("user", "hi", 0)]);
assert_eq!(
out,
vec![serde_json::json!({"role": "user", "content": "hi"})]
);
}
#[test]
fn images_expand_to_structured_content_array_with_text_last() {
let out = build_json_messages(&[entry("user", "look", 2)]);
assert_eq!(
out,
vec![serde_json::json!({
"role": "user",
"content": [
{"type": "image"},
{"type": "image"},
{"type": "text", "text": "look"}
]
})]
);
}
#[test]
fn empty_text_with_images_omits_text_part() {
let out = build_json_messages(&[entry("user", "", 1)]);
assert_eq!(
out,
vec![serde_json::json!({"role": "user", "content": [{"type": "image"}]})]
);
}
#[test]
fn media_markers_render_in_the_clients_order() {
use crate::ir::MediaKind;
let mut e = entry("user", "which came first?", 0);
e.media = vec![MediaKind::Video, MediaKind::Image, MediaKind::Video];
let out = build_json_messages(&[e]);
assert_eq!(
out,
vec![serde_json::json!({
"role": "user",
"content": [
{"type": "video"},
{"type": "image"},
{"type": "video"},
{"type": "text", "text": "which came first?"}
]
})]
);
}
#[test]
fn tool_calls_and_reasoning_are_attached() {
let mut e = entry("assistant", "", 0);
e.tool_calls = Some(vec![serde_json::json!({"id": "c1"})]);
e.reasoning_content = Some("because".to_string());
let out = build_json_messages(&[e]);
assert_eq!(
out,
vec![serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{"id": "c1"}],
"reasoning_content": "because"
})]
);
}
}